Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

published graphql package as swell and moved to separate repo

+1102 -8665
-2
Dockerfile
··· 17 18 # Add local dependencies first (these change less frequently) 19 COPY ./lexicon /build/lexicon 20 - COPY ./graphql /build/graphql 21 COPY ./lexicon_graphql /build/lexicon_graphql 22 23 # Add server code ··· 30 31 # Install dependencies for all projects 32 RUN cd /build/lexicon && gleam deps download 33 - RUN cd /build/graphql && gleam deps download 34 RUN cd /build/lexicon_graphql && gleam deps download 35 RUN cd /build/server && gleam deps download 36
··· 17 18 # Add local dependencies first (these change less frequently) 19 COPY ./lexicon /build/lexicon 20 COPY ./lexicon_graphql /build/lexicon_graphql 21 22 # Add server code ··· 29 30 # Install dependencies for all projects 31 RUN cd /build/lexicon && gleam deps download 32 RUN cd /build/lexicon_graphql && gleam deps download 33 RUN cd /build/server && gleam deps download 34
-8
Makefile
··· 11 12 # Build all projects 13 build: 14 - @echo "Building graphql package..." 15 - @cd graphql && gleam build 16 - @echo "" 17 @echo "Building lexicon_graphql package..." 18 @cd lexicon_graphql && gleam build 19 @echo "" ··· 24 25 # Run all tests 26 test: build 27 - @echo "Running graphql tests..." 28 - @cd graphql && gleam test 29 - @echo "" 30 @echo "Running lexicon_graphql tests..." 31 @cd lexicon_graphql && gleam test 32 @echo "" ··· 42 43 # Clean build artifacts 44 clean: 45 - @echo "Cleaning build artifacts..." 46 - @cd graphql && gleam clean 47 @cd lexicon_graphql && gleam clean 48 @cd server && gleam clean 49 @echo "Clean complete"
··· 11 12 # Build all projects 13 build: 14 @echo "Building lexicon_graphql package..." 15 @cd lexicon_graphql && gleam build 16 @echo "" ··· 21 22 # Run all tests 23 test: build 24 @echo "Running lexicon_graphql tests..." 25 @cd lexicon_graphql && gleam test 26 @echo "" ··· 36 37 # Clean build artifacts 38 clean: 39 @cd lexicon_graphql && gleam clean 40 @cd server && gleam clean 41 @echo "Clean complete"
-1
README.md
··· 84 85 ## Architecture 86 87 - - `graphql/` - Core GraphQL implementation 88 - `lexicon_graphql/` - Schema generation from Lexicons 89 - `server/` - Database layer and record storage
··· 84 85 ## Architecture 86 87 - `lexicon_graphql/` - Schema generation from Lexicons 88 - `server/` - Database layer and record storage
-4
graphql/.gitignore
··· 1 - *.beam 2 - *.ez 3 - /build 4 - erl_crash.dump
···
-381
graphql/README.md
··· 1 - # GraphQL 2 - 3 - A GraphQL implementation in Gleam providing query parsing, execution, and introspection support. 4 - 5 - ## Features 6 - 7 - ### Core GraphQL Functionality 8 - - **Query Parsing**: GraphQL query language support including: 9 - - Field selection 10 - - Arguments 11 - - Aliases 12 - - Fragments (inline and named) 13 - - Mutations (named and anonymous) 14 - - Subscriptions (named and anonymous) 15 - 16 - - **Schema Definition**: Type-safe schema builder with: 17 - - Object types 18 - - Input object types 19 - - Scalar types (String, Int, Float, Boolean, ID) 20 - - List types 21 - - Non-null types 22 - - Field resolvers with context-based data access 23 - - Mutation support with argument handling 24 - 25 - - **Query Execution**: Execution engine with: 26 - - Recursive field resolution 27 - - Nested object support 28 - - List handling with proper field filtering 29 - - Fragment spreading and inline fragments 30 - - Error collection and reporting 31 - - Path tracking for error context 32 - 33 - - **Mutation Execution**: Mutation engine with: 34 - - Named mutations (`mutation CreateUser { ... }`) 35 - - Anonymous mutations (`mutation { ... }`) 36 - - Input type validation 37 - - Context-based authentication and authorization 38 - - Error handling and reporting 39 - 40 - - **Subscription Execution**: Subscription engine with: 41 - - Named subscriptions (`subscription OnMessage { ... }`) 42 - - Anonymous subscriptions (`subscription { ... }`) 43 - - Real-time event streaming via WebSocket 44 - - Field selection and nested queries 45 - - Context-based event data passing 46 - - Full support for joins and complex resolvers 47 - 48 - - **Introspection**: Full GraphQL introspection support 49 - - Schema introspection queries 50 - - Type introspection (including mutation types) 51 - - Field introspection 52 - - Input type introspection 53 - - Compatible with GraphiQL and other GraphQL clients 54 - 55 - ## Architecture 56 - 57 - The package is organized into several modules: 58 - 59 - - `graphql/lexer.gleam` - Tokenizes GraphQL query strings 60 - - `graphql/parser.gleam` - Parses tokens into an AST 61 - - `graphql/schema.gleam` - Schema definition and type system 62 - - `graphql/executor.gleam` - Query execution engine 63 - - `graphql/value.gleam` - GraphQL value types 64 - - `graphql/introspection.gleam` - Schema introspection 65 - 66 - ## Usage 67 - 68 - ### Defining a Schema 69 - 70 - ```gleam 71 - import graphql/schema 72 - import graphql/value 73 - 74 - // Define a simple User type 75 - let user_type = schema.object_type( 76 - "User", 77 - "A user in the system", 78 - [ 79 - schema.field("id", schema.id_type(), "User ID", fn(ctx) { 80 - // Extract id from context 81 - case ctx.data { 82 - option.Some(value.Object(fields)) -> { 83 - case list.key_find(fields, "id") { 84 - Ok(id_val) -> Ok(id_val) 85 - Error(_) -> Ok(value.Null) 86 - } 87 - } 88 - _ -> Ok(value.Null) 89 - } 90 - }), 91 - schema.field("name", schema.string_type(), "User name", fn(ctx) { 92 - // Extract name from context 93 - // ... resolver implementation 94 - }), 95 - ] 96 - ) 97 - 98 - // Define root query type 99 - let query_type = schema.object_type( 100 - "Query", 101 - "Root query type", 102 - [ 103 - schema.field("user", user_type, "Get a user", fn(_ctx) { 104 - Ok(value.Object([ 105 - #("id", value.String("1")), 106 - #("name", value.String("Alice")), 107 - ])) 108 - }), 109 - ] 110 - ) 111 - 112 - // Define input type for creating users 113 - let create_user_input = schema.input_object_type( 114 - "CreateUserInput", 115 - "Input for creating a user", 116 - [ 117 - schema.input_field("name", schema.non_null(schema.string_type()), "User name", option.None), 118 - ] 119 - ) 120 - 121 - // Define mutation type 122 - let mutation_type = schema.object_type( 123 - "Mutation", 124 - "Root mutation type", 125 - [ 126 - schema.field_with_args( 127 - "createUser", 128 - user_type, 129 - "Create a new user", 130 - [schema.argument("input", schema.non_null(create_user_input), "User data", option.None)], 131 - fn(ctx) { 132 - // Extract input from arguments and create user 133 - case schema.get_argument(ctx, "input") { 134 - option.Some(input) -> { 135 - Ok(value.Object([ 136 - #("id", value.String("2")), 137 - #("name", value.String("Bob")), 138 - ])) 139 - } 140 - option.None -> Error("Missing input argument") 141 - } 142 - } 143 - ), 144 - ] 145 - ) 146 - 147 - // Create schema with mutations 148 - let my_schema = schema.new(query_type) 149 - |> schema.with_mutation(mutation_type) 150 - ``` 151 - 152 - ### Executing Queries 153 - 154 - ```gleam 155 - import graphql/executor 156 - import graphql/schema 157 - 158 - let query = "{ user { id name } }" 159 - let ctx = schema.context(option.None) 160 - let result = executor.execute(query, my_schema, ctx) 161 - 162 - case result { 163 - Ok(executor.Response(data: data, errors: [])) -> { 164 - // Query succeeded 165 - io.println("Data: " <> string.inspect(data)) 166 - } 167 - Ok(executor.Response(data: data, errors: errors)) -> { 168 - // Query executed with errors 169 - io.println("Data: " <> string.inspect(data)) 170 - io.println("Errors: " <> string.inspect(errors)) 171 - } 172 - Error(err) -> { 173 - // Query failed to parse or execute 174 - io.println("Error: " <> err) 175 - } 176 - } 177 - ``` 178 - 179 - ### Executing Mutations 180 - 181 - ```gleam 182 - import graphql/executor 183 - import graphql/schema 184 - import graphql/value 185 - 186 - let mutation = " 187 - mutation { 188 - createUser(input: { name: \"Bob\" }) { 189 - id 190 - name 191 - } 192 - } 193 - " 194 - 195 - // Create context with optional data (e.g., auth token) 196 - let ctx_data = value.Object([ 197 - #("auth_token", value.String("some_token")) 198 - ]) 199 - let ctx = schema.context(option.Some(ctx_data)) 200 - 201 - let result = executor.execute(mutation, my_schema, ctx) 202 - 203 - case result { 204 - Ok(executor.Response(data: data, errors: [])) -> { 205 - // Mutation succeeded 206 - io.println("Created user: " <> string.inspect(data)) 207 - } 208 - Ok(executor.Response(data: data, errors: errors)) -> { 209 - // Mutation executed with errors 210 - io.println("Errors: " <> string.inspect(errors)) 211 - } 212 - Error(err) -> { 213 - io.println("Error: " <> err) 214 - } 215 - } 216 - ``` 217 - 218 - ### Defining and Using Subscriptions 219 - 220 - ```gleam 221 - import graphql/schema 222 - import graphql/executor 223 - import graphql/value 224 - 225 - // Define a Message type 226 - let message_type = schema.object_type( 227 - "Message", 228 - "A chat message", 229 - [ 230 - schema.field("id", schema.id_type(), "Message ID", fn(ctx) { 231 - // Extract from context data 232 - case ctx.data { 233 - option.Some(value.Object(fields)) -> { 234 - case list.key_find(fields, "id") { 235 - Ok(id_val) -> Ok(id_val) 236 - Error(_) -> Ok(value.Null) 237 - } 238 - } 239 - _ -> Ok(value.Null) 240 - } 241 - }), 242 - schema.field("content", schema.string_type(), "Message content", fn(ctx) { 243 - case ctx.data { 244 - option.Some(value.Object(fields)) -> { 245 - case list.key_find(fields, "content") { 246 - Ok(content) -> Ok(content) 247 - Error(_) -> Ok(value.Null) 248 - } 249 - } 250 - _ -> Ok(value.Null) 251 - } 252 - }), 253 - ] 254 - ) 255 - 256 - // Define subscription type 257 - let subscription_type = schema.object_type( 258 - "Subscription", 259 - "Root subscription type", 260 - [ 261 - schema.field( 262 - "messageAdded", 263 - message_type, 264 - "Subscribe to new messages", 265 - fn(ctx) { 266 - // For subscriptions, the event data is passed via ctx.data 267 - // Return it directly - the executor will handle field selection 268 - case ctx.data { 269 - option.Some(data) -> Ok(data) 270 - option.None -> Error("Subscription called without event data") 271 - } 272 - } 273 - ), 274 - ] 275 - ) 276 - 277 - // Create schema with subscriptions 278 - let my_schema = schema.schema_with_subscriptions( 279 - query_type, 280 - option.Some(mutation_type), 281 - option.Some(subscription_type) 282 - ) 283 - ``` 284 - 285 - ### Executing Subscriptions 286 - 287 - ```gleam 288 - import graphql/executor 289 - import graphql/schema 290 - import graphql/value 291 - 292 - // Subscription query with field selection 293 - let subscription = " 294 - subscription { 295 - messageAdded { 296 - id 297 - content 298 - } 299 - } 300 - " 301 - 302 - // When an event occurs, create context with the event data 303 - let event_data = value.Object([ 304 - #("id", value.String("msg_123")), 305 - #("content", value.String("Hello, world!")), 306 - #("timestamp", value.String("2024-01-01T00:00:00Z")), 307 - ]) 308 - 309 - let ctx = schema.context(option.Some(event_data)) 310 - 311 - // Execute the subscription query with the event data 312 - let result = executor.execute(subscription, my_schema, ctx) 313 - 314 - case result { 315 - Ok(executor.Response(data: data, errors: [])) -> { 316 - // Subscription resolved successfully 317 - // Only requested fields (id, content) will be in the response 318 - // timestamp is filtered out by field selection 319 - io.println("Event: " <> string.inspect(data)) 320 - } 321 - Ok(executor.Response(data: data, errors: errors)) -> { 322 - io.println("Errors: " <> string.inspect(errors)) 323 - } 324 - Error(err) -> { 325 - io.println("Error: " <> err) 326 - } 327 - } 328 - ``` 329 - 330 - **Key Points for Subscriptions:** 331 - 332 - 1. **Event Data via Context**: Unlike queries/mutations, subscription field resolvers receive event data through `ctx.data` and should return it directly. 333 - 334 - 2. **Field Selection Works**: The executor automatically handles field selection - you pass the full event object, and only requested fields are returned. 335 - 336 - 3. **Nested Queries Supported**: Subscriptions support the same nested queries, joins, and complex resolvers as regular queries. 337 - 338 - 4. **WebSocket Integration**: In production, subscriptions are typically used with WebSocket connections where: 339 - - Client subscribes via `subscription { ... }` 340 - - Server listens for events (e.g., PubSub) 341 - - When events occur, execute the subscription query with event data 342 - - Send results back to client over WebSocket 343 - 344 - ## Test Coverage 345 - 346 - The package includes comprehensive tests covering: 347 - - Query parsing (queries, mutations, subscriptions) 348 - - Query execution with field selection 349 - - Mutation execution with input types 350 - - Subscription execution with event data 351 - - Schema definition and validation 352 - - Introspection (queries, mutations, and subscription types) 353 - - Input type handling 354 - - Fragment support (inline and named) 355 - - Error handling and edge cases 356 - - Snapshot tests for parser validation 357 - 358 - ## Known Limitations 359 - 360 - - Directives not yet implemented 361 - - Variables not yet fully implemented 362 - - Custom scalar types limited to built-in types 363 - - Union types not yet fully implemented 364 - - Interface types not yet implemented 365 - 366 - ## Dependencies 367 - 368 - - `gleam_stdlib` >= 0.44.0 369 - 370 - ## Development 371 - 372 - Run tests: 373 - ```sh 374 - cd graphql 375 - gleam test 376 - ``` 377 - 378 - Build: 379 - ```sh 380 - gleam build 381 - ```
···
-15
graphql/birdie_snapshots/built_in_scalar_types.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Built-in scalar types 4 - file: ./test/sdl_test.gleam 5 - test_name: built_in_scalars_test 6 - --- 7 - scalar String 8 - 9 - scalar Int 10 - 11 - scalar Float 12 - 13 - scalar Boolean 14 - 15 - scalar ID
···
-8
graphql/birdie_snapshots/empty_enum.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Empty enum 4 - file: ./test/sdl_test.gleam 5 - test_name: empty_enum_test 6 - --- 7 - """An empty enum""" 8 - enum EmptyEnum {}
···
-8
graphql/birdie_snapshots/empty_input_object.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Empty input object 4 - file: ./test/sdl_test.gleam 5 - test_name: empty_input_object_test 6 - --- 7 - """An empty input""" 8 - input EmptyInput {}
···
-11
graphql/birdie_snapshots/enum_without_descriptions.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Enum without descriptions 4 - file: ./test/sdl_test.gleam 5 - test_name: enum_without_descriptions_test 6 - --- 7 - enum Color { 8 - RED 9 - GREEN 10 - BLUE 11 - }
···
-7
graphql/birdie_snapshots/execute_field_with_object_argument.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute field with object argument 4 - file: ./test/executor_test.gleam 5 - test_name: execute_field_receives_object_argument_test 6 - --- 7 - Response(Object([#("posts", String("Sorting by date DESC"))]), [])
···
-7
graphql/birdie_snapshots/execute_field_with_string_argument.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute field with string argument 4 - file: ./test/executor_test.gleam 5 - test_name: execute_field_receives_string_argument_test 6 - --- 7 - Response(Object([#("echo", String("Echo: hello"))]), [])
···
-7
graphql/birdie_snapshots/execute_list_with_nested_selections.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute list with nested selections 4 - file: ./test/executor_test.gleam 5 - test_name: execute_list_with_nested_selections_test 6 - --- 7 - Response(Object([#("users", List([Object([#("id", String("1")), #("name", String("Alice"))]), Object([#("id", String("2")), #("name", String("Bob"))])]))]), [])
···
-7
graphql/birdie_snapshots/execute_mixed_aliased_and_non_aliased_fields.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute mixed aliased and non-aliased fields 4 - file: ./test/executor_test.gleam 5 - test_name: execute_mixed_aliased_fields_test 6 - --- 7 - Response(Object([#("greeting", String("world")), #("number", Int(42))]), [])
···
-7
graphql/birdie_snapshots/execute_multiple_fields_with_aliases.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute multiple fields with aliases 4 - file: ./test/executor_test.gleam 5 - test_name: execute_multiple_fields_with_aliases_test 6 - --- 7 - Response(Object([#("greeting", String("world")), #("num", Int(42))]), [])
···
-7
graphql/birdie_snapshots/execute_multiple_mutations.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute multiple mutations 4 - file: ./test/mutation_execution_test.gleam 5 - test_name: execute_multiple_mutations_test 6 - --- 7 - Response(Object([#("createUser", Object([#("id", String("123")), #("name", String("Alice"))])), #("deleteUser", Boolean(True))]), [])
···
-7
graphql/birdie_snapshots/execute_query_with_int_variable.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute query with int variable 4 - file: ./test/executor_test.gleam 5 - test_name: execute_query_with_variable_int_test 6 - --- 7 - Response(Object([#("user", String("User #42"))]), [])
···
-7
graphql/birdie_snapshots/execute_query_with_multiple_variables.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute query with multiple variables 4 - file: ./test/executor_test.gleam 5 - test_name: execute_query_with_multiple_variables_test 6 - --- 7 - Response(Object([#("search", String("Searching for 'graphql' (limit: 10)"))]), [])
···
-7
graphql/birdie_snapshots/execute_query_with_string_variable.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute query with string variable 4 - file: ./test/executor_test.gleam 5 - test_name: execute_query_with_variable_string_test 6 - --- 7 - Response(Object([#("greet", String("Hello, Alice!"))]), [])
···
-7
graphql/birdie_snapshots/execute_schema_introspection.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute __schema introspection 4 - file: ./test/executor_test.gleam 5 - test_name: execute_schema_introspection_query_type_test 6 - --- 7 - Response(Object([#("__schema", Object([#("queryType", Object([#("name", String("Query"))]))]))]), [])
···
-7
graphql/birdie_snapshots/execute_simple_fragment_spread.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute simple fragment spread 4 - file: ./test/executor_test.gleam 5 - test_name: execute_simple_fragment_spread_test 6 - --- 7 - Response(Object([#("user", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
···
-7
graphql/birdie_snapshots/execute_simple_mutation.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute simple mutation 4 - file: ./test/mutation_execution_test.gleam 5 - test_name: execute_simple_mutation_test 6 - --- 7 - Response(Object([#("createUser", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
···
-7
graphql/birdie_snapshots/execute_simple_query.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute simple query 4 - file: ./test/executor_test.gleam 5 - test_name: execute_simple_query_test 6 - --- 7 - Response(Object([#("hello", String("world"))]), [])
···
-7
graphql/birdie_snapshots/execute_typename_introspection.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute __typename introspection 4 - file: ./test/executor_test.gleam 5 - test_name: execute_typename_introspection_test 6 - --- 7 - Response(Object([#("__typename", String("Query"))]), [])
···
-7
graphql/birdie_snapshots/execute_typename_with_regular_fields.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute __typename with regular fields 4 - file: ./test/executor_test.gleam 5 - test_name: execute_typename_with_regular_fields_test 6 - --- 7 - Response(Object([#("__typename", String("Query")), #("hello", String("world"))]), [])
···
-7
graphql/birdie_snapshots/execute_union_list_with_inline_fragments.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute union list with inline fragments 4 - file: ./test/executor_test.gleam 5 - test_name: execute_union_list_with_inline_fragments_test 6 - --- 7 - Response(Object([#("searchAll", List([Object([#("title", String("First Post"))]), Object([#("text", String("Great article!"))]), Object([#("title", String("Second Post"))])]))]), [])
···
-7
graphql/birdie_snapshots/execute_union_with_inline_fragment.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Execute union with inline fragment 4 - file: ./test/executor_test.gleam 5 - test_name: execute_union_with_inline_fragment_test 6 - --- 7 - Response(Object([#("search", Object([#("title", String("GraphQL is awesome")), #("content", String("Learn all about GraphQL..."))]))]), [])
···
-13
graphql/birdie_snapshots/input_object_with_default_values.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Input object with default values 4 - file: ./test/sdl_test.gleam 5 - test_name: input_object_with_default_values_test 6 - --- 7 - """Filter options for queries""" 8 - input FilterInput { 9 - """Maximum number of results""" 10 - limit: Int 11 - """Number of results to skip""" 12 - offset: Int 13 - }
···
-15
graphql/birdie_snapshots/multiple_mutations_(_crud_operations).accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Multiple mutations (CRUD operations) 4 - file: ./test/mutation_sdl_test.gleam 5 - test_name: multiple_mutations_test 6 - --- 7 - """Mutations""" 8 - type Mutation { 9 - """Create a user""" 10 - createUser: User 11 - """Update a user""" 12 - updateUser: User 13 - """Delete a user""" 14 - deleteUser: DeleteResponse 15 - }
···
-7
graphql/birdie_snapshots/multiple_mutations_in_one_operation.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Multiple mutations in one operation 4 - file: ./test/mutation_parser_test.gleam 5 - test_name: parse_multiple_mutations_test 6 - --- 7 - Document([Mutation(SelectionSet([Field("createUser", None, [Argument("name", StringValue("Alice"))], [Field("id", None, [], [])]), Field("deleteUser", None, [Argument("id", StringValue("123"))], [Field("success", None, [], [])])]))])
···
-11
graphql/birdie_snapshots/mutation_returning_list.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Mutation returning list 4 - file: ./test/mutation_sdl_test.gleam 5 - test_name: mutation_returning_list_test 6 - --- 7 - """Mutations""" 8 - type Mutation { 9 - """Create multiple users""" 10 - createUsers: [User] 11 - }
···
-29
graphql/birdie_snapshots/mutation_with_input_object_argument.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Mutation with input object argument 4 - file: ./test/mutation_sdl_test.gleam 5 - test_name: mutation_with_input_object_test 6 - --- 7 - """Input for creating a user""" 8 - input CreateUserInput { 9 - """User name""" 10 - name: String! 11 - """Email address""" 12 - email: String! 13 - """Age""" 14 - age: Int 15 - } 16 - 17 - """A user""" 18 - type User { 19 - """User ID""" 20 - id: ID 21 - """User name""" 22 - name: String 23 - } 24 - 25 - """Mutations""" 26 - type Mutation { 27 - """Create a new user""" 28 - createUser: User 29 - }
···
-7
graphql/birdie_snapshots/mutation_with_nested_selections.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Mutation with nested selections 4 - file: ./test/mutation_parser_test.gleam 5 - test_name: parse_mutation_with_nested_selections_test 6 - --- 7 - Document([Mutation(SelectionSet([Field("createPost", None, [Argument("input", ObjectValue([#("title", StringValue("Hello"))]))], [Field("id", None, [], []), Field("author", None, [], [Field("id", None, [], []), Field("name", None, [], [])]), Field("tags", None, [], [])])]))])
···
-11
graphql/birdie_snapshots/mutation_with_non_null_return_type.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Mutation with non-null return type 4 - file: ./test/mutation_sdl_test.gleam 5 - test_name: mutation_with_non_null_return_test 6 - --- 7 - """Mutations""" 8 - type Mutation { 9 - """Create a user (guaranteed to return)""" 10 - createUser: User! 11 - }
···
-7
graphql/birdie_snapshots/named_mutation.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Named mutation 4 - file: ./test/mutation_parser_test.gleam 5 - test_name: parse_named_mutation_test 6 - --- 7 - Document([NamedMutation("CreateUser", [], SelectionSet([Field("createUser", None, [Argument("name", StringValue("Alice"))], [Field("id", None, [], []), Field("name", None, [], [])])]))])
···
-7
graphql/birdie_snapshots/named_subscription.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Named subscription 4 - file: ./test/subscription_parser_test.gleam 5 - test_name: parse_named_subscription_test 6 - --- 7 - Document([NamedSubscription("OnMessage", [], SelectionSet([Field("messageAdded", None, [], [Field("id", None, [], []), Field("content", None, [], [])])]))])
···
-21
graphql/birdie_snapshots/nested_input_types.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Nested input types 4 - file: ./test/sdl_test.gleam 5 - test_name: nested_input_types_test 6 - --- 7 - """Street address information""" 8 - input AddressInput { 9 - """Street name""" 10 - street: String 11 - """City name""" 12 - city: String 13 - } 14 - 15 - """User information""" 16 - input UserInput { 17 - """Full name""" 18 - name: String 19 - """Home address""" 20 - address: AddressInput 21 - }
···
-15
graphql/birdie_snapshots/object_type_with_list_fields.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Object type with list fields 4 - file: ./test/sdl_test.gleam 5 - test_name: object_with_list_fields_test 6 - --- 7 - """A blog post""" 8 - type Post { 9 - """Post ID""" 10 - id: ID 11 - """Post title""" 12 - title: String 13 - """Post tags""" 14 - tags: [String!] 15 - }
···
-7
graphql/birdie_snapshots/parse_mutation_with_input_object_argument.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Parse mutation with input object argument 4 - file: ./test/mutation_parser_test.gleam 5 - test_name: parse_mutation_with_input_object_test 6 - --- 7 - Document([Mutation(SelectionSet([Field("createUser", None, [Argument("input", ObjectValue([#("name", StringValue("Alice")), #("email", StringValue("alice@example.com")), #("age", IntValue("30"))]))], [Field("id", None, [], []), Field("name", None, [], []), Field("email", None, [], [])])]))])
···
-7
graphql/birdie_snapshots/simple_anonymous_mutation.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Simple anonymous mutation 4 - file: ./test/mutation_parser_test.gleam 5 - test_name: parse_simple_anonymous_mutation_test 6 - --- 7 - Document([Mutation(SelectionSet([Field("createUser", None, [Argument("name", StringValue("Alice"))], [Field("id", None, [], []), Field("name", None, [], [])])]))])
···
-7
graphql/birdie_snapshots/simple_anonymous_subscription.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Simple anonymous subscription 4 - file: ./test/subscription_parser_test.gleam 5 - test_name: parse_simple_anonymous_subscription_test 6 - --- 7 - Document([Subscription(SelectionSet([Field("messageAdded", None, [], [Field("content", None, [], []), Field("author", None, [], [])])]))])
···
-17
graphql/birdie_snapshots/simple_enum_type.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Simple enum type 4 - file: ./test/sdl_test.gleam 5 - test_name: simple_enum_test 6 - --- 7 - """Order status""" 8 - enum Status { 9 - """Order is pending""" 10 - PENDING 11 - """Order is being processed""" 12 - PROCESSING 13 - """Order has been shipped""" 14 - SHIPPED 15 - """Order has been delivered""" 16 - DELIVERED 17 - }
···
-15
graphql/birdie_snapshots/simple_input_object_with_descriptions.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Simple input object with descriptions 4 - file: ./test/sdl_test.gleam 5 - test_name: simple_input_object_test 6 - --- 7 - """Input for creating or updating a user""" 8 - input UserInput { 9 - """User's name""" 10 - name: String 11 - """User's email address""" 12 - email: String! 13 - """User's age""" 14 - age: Int 15 - }
···
-11
graphql/birdie_snapshots/simple_mutation_type.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Simple mutation type 4 - file: ./test/mutation_sdl_test.gleam 5 - test_name: simple_mutation_type_test 6 - --- 7 - """Root mutation type""" 8 - type Mutation { 9 - """Create a new user""" 10 - createUser: User 11 - }
···
-15
graphql/birdie_snapshots/simple_object_type.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Simple object type 4 - file: ./test/sdl_test.gleam 5 - test_name: simple_object_type_test 6 - --- 7 - """A user in the system""" 8 - type User { 9 - """User ID""" 10 - id: ID! 11 - """User's name""" 12 - name: String 13 - """Email address""" 14 - email: String 15 - }
···
-7
graphql/birdie_snapshots/subscription_with_nested_selections.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Subscription with nested selections 4 - file: ./test/subscription_parser_test.gleam 5 - test_name: parse_subscription_with_nested_selections_test 6 - --- 7 - Document([Subscription(SelectionSet([Field("postCreated", None, [], [Field("id", None, [], []), Field("title", None, [], []), Field("author", None, [], [Field("id", None, [], []), Field("name", None, [], []), Field("email", None, [], [])]), Field("comments", None, [], [Field("content", None, [], [])])])]))])
···
-19
graphql/birdie_snapshots/type_with_non_null_and_list_modifiers.accepted
··· 1 - --- 2 - version: 1.4.1 3 - title: Type with NonNull and List modifiers 4 - file: ./test/sdl_test.gleam 5 - test_name: type_with_non_null_and_list_test 6 - --- 7 - """Complex type modifiers""" 8 - input ComplexInput { 9 - """Required string""" 10 - required: String! 11 - """Optional list of strings""" 12 - optionalList: [String] 13 - """Required list of optional strings""" 14 - requiredList: [String]! 15 - """Optional list of required strings""" 16 - listOfRequired: [String!] 17 - """Required list of required strings""" 18 - requiredListOfRequired: [String!]! 19 - }
···
-20
graphql/gleam.toml
··· 1 - name = "graphql" 2 - version = "1.0.0" 3 - 4 - # Fill out these fields if you intend to generate HTML documentation or publish 5 - # your project to the Hex package manager. 6 - # 7 - # description = "" 8 - # licences = ["Apache-2.0"] 9 - # repository = { type = "github", user = "", repo = "" } 10 - # links = [{ title = "Website", href = "" }] 11 - # 12 - # For a full reference of all the available options, you can have a look at 13 - # https://gleam.run/writing-gleam/gleam-toml/. 14 - 15 - [dependencies] 16 - gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 - 18 - [dev-dependencies] 19 - gleeunit = ">= 1.0.0 and < 2.0.0" 20 - birdie = ">= 1.0.0 and < 2.0.0"
···
-28
graphql/manifest.toml
··· 1 - # This file was generated by Gleam 2 - # You typically do not need to edit this file 3 - 4 - packages = [ 5 - { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 - { name = "birdie", version = "1.4.1", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "justin", "rank", "simplifile", "term_size", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "18599E478C14BD9EBD2465F0561F96EB9B58A24DB44AF86F103EF81D4B9834BF" }, 7 - { name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" }, 8 - { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 - { name = "glance", version = "5.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "7F216D97935465FF4AC46699CD1C3E0FB19CB678B002E4ACAFCE256E96312F14" }, 10 - { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, 11 - { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, 12 - { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 13 - { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 14 - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 15 - { name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" }, 16 - { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 17 - { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 18 - { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 19 - { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 20 - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, 21 - { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 22 - { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 23 - ] 24 - 25 - [requirements] 26 - birdie = { version = ">= 1.0.0 and < 2.0.0" } 27 - gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 28 - gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
···
-318
graphql/src/graphql/connection.gleam
··· 1 - /// GraphQL Connection Types for Relay Cursor Connections 2 - /// 3 - /// Implements the Relay Cursor Connections Specification: 4 - /// https://relay.dev/graphql/connections.htm 5 - import gleam/list 6 - import gleam/option.{type Option, None, Some} 7 - import graphql/schema 8 - import graphql/value 9 - 10 - /// PageInfo type for connection pagination metadata 11 - pub type PageInfo { 12 - PageInfo( 13 - has_next_page: Bool, 14 - has_previous_page: Bool, 15 - start_cursor: Option(String), 16 - end_cursor: Option(String), 17 - ) 18 - } 19 - 20 - /// Edge wrapper containing a node and its cursor 21 - pub type Edge(node_type) { 22 - Edge(node: node_type, cursor: String) 23 - } 24 - 25 - /// Connection wrapper containing edges and page info 26 - pub type Connection(node_type) { 27 - Connection( 28 - edges: List(Edge(node_type)), 29 - page_info: PageInfo, 30 - total_count: Option(Int), 31 - ) 32 - } 33 - 34 - /// Creates the PageInfo GraphQL type 35 - pub fn page_info_type() -> schema.Type { 36 - schema.object_type( 37 - "PageInfo", 38 - "Information about pagination in a connection", 39 - [ 40 - schema.field( 41 - "hasNextPage", 42 - schema.non_null(schema.boolean_type()), 43 - "When paginating forwards, are there more items?", 44 - fn(ctx) { 45 - // Extract from context data 46 - case ctx.data { 47 - Some(value.Object(fields)) -> { 48 - case list.key_find(fields, "hasNextPage") { 49 - Ok(val) -> Ok(val) 50 - Error(_) -> Ok(value.Boolean(False)) 51 - } 52 - } 53 - _ -> Ok(value.Boolean(False)) 54 - } 55 - }, 56 - ), 57 - schema.field( 58 - "hasPreviousPage", 59 - schema.non_null(schema.boolean_type()), 60 - "When paginating backwards, are there more items?", 61 - fn(ctx) { 62 - case ctx.data { 63 - Some(value.Object(fields)) -> { 64 - case list.key_find(fields, "hasPreviousPage") { 65 - Ok(val) -> Ok(val) 66 - Error(_) -> Ok(value.Boolean(False)) 67 - } 68 - } 69 - _ -> Ok(value.Boolean(False)) 70 - } 71 - }, 72 - ), 73 - schema.field( 74 - "startCursor", 75 - schema.string_type(), 76 - "Cursor corresponding to the first item in the page", 77 - fn(ctx) { 78 - case ctx.data { 79 - Some(value.Object(fields)) -> { 80 - case list.key_find(fields, "startCursor") { 81 - Ok(val) -> Ok(val) 82 - Error(_) -> Ok(value.Null) 83 - } 84 - } 85 - _ -> Ok(value.Null) 86 - } 87 - }, 88 - ), 89 - schema.field( 90 - "endCursor", 91 - schema.string_type(), 92 - "Cursor corresponding to the last item in the page", 93 - fn(ctx) { 94 - case ctx.data { 95 - Some(value.Object(fields)) -> { 96 - case list.key_find(fields, "endCursor") { 97 - Ok(val) -> Ok(val) 98 - Error(_) -> Ok(value.Null) 99 - } 100 - } 101 - _ -> Ok(value.Null) 102 - } 103 - }, 104 - ), 105 - ], 106 - ) 107 - } 108 - 109 - /// Creates an Edge type for a given node type name 110 - pub fn edge_type(node_type_name: String, node_type: schema.Type) -> schema.Type { 111 - let edge_type_name = node_type_name <> "Edge" 112 - 113 - schema.object_type( 114 - edge_type_name, 115 - "An edge in a connection for " <> node_type_name, 116 - [ 117 - schema.field( 118 - "node", 119 - schema.non_null(node_type), 120 - "The item at the end of the edge", 121 - fn(ctx) { 122 - // Extract node from context data 123 - case ctx.data { 124 - Some(value.Object(fields)) -> { 125 - case list.key_find(fields, "node") { 126 - Ok(val) -> Ok(val) 127 - Error(_) -> Ok(value.Null) 128 - } 129 - } 130 - _ -> Ok(value.Null) 131 - } 132 - }, 133 - ), 134 - schema.field( 135 - "cursor", 136 - schema.non_null(schema.string_type()), 137 - "A cursor for use in pagination", 138 - fn(ctx) { 139 - case ctx.data { 140 - Some(value.Object(fields)) -> { 141 - case list.key_find(fields, "cursor") { 142 - Ok(val) -> Ok(val) 143 - Error(_) -> Ok(value.String("")) 144 - } 145 - } 146 - _ -> Ok(value.String("")) 147 - } 148 - }, 149 - ), 150 - ], 151 - ) 152 - } 153 - 154 - /// Creates a Connection type for a given node type name 155 - pub fn connection_type( 156 - node_type_name: String, 157 - edge_type: schema.Type, 158 - ) -> schema.Type { 159 - let connection_type_name = node_type_name <> "Connection" 160 - 161 - schema.object_type( 162 - connection_type_name, 163 - "A connection to a list of items for " <> node_type_name, 164 - [ 165 - schema.field( 166 - "edges", 167 - schema.non_null(schema.list_type(schema.non_null(edge_type))), 168 - "A list of edges", 169 - fn(ctx) { 170 - // Extract edges from context data 171 - case ctx.data { 172 - Some(value.Object(fields)) -> { 173 - case list.key_find(fields, "edges") { 174 - Ok(val) -> Ok(val) 175 - Error(_) -> Ok(value.List([])) 176 - } 177 - } 178 - _ -> Ok(value.List([])) 179 - } 180 - }, 181 - ), 182 - schema.field( 183 - "pageInfo", 184 - schema.non_null(page_info_type()), 185 - "Information to aid in pagination", 186 - fn(ctx) { 187 - // Extract pageInfo from context data 188 - case ctx.data { 189 - Some(value.Object(fields)) -> { 190 - case list.key_find(fields, "pageInfo") { 191 - Ok(val) -> Ok(val) 192 - Error(_) -> 193 - Ok( 194 - value.Object([ 195 - #("hasNextPage", value.Boolean(False)), 196 - #("hasPreviousPage", value.Boolean(False)), 197 - #("startCursor", value.Null), 198 - #("endCursor", value.Null), 199 - ]), 200 - ) 201 - } 202 - } 203 - _ -> 204 - Ok( 205 - value.Object([ 206 - #("hasNextPage", value.Boolean(False)), 207 - #("hasPreviousPage", value.Boolean(False)), 208 - #("startCursor", value.Null), 209 - #("endCursor", value.Null), 210 - ]), 211 - ) 212 - } 213 - }, 214 - ), 215 - schema.field( 216 - "totalCount", 217 - schema.int_type(), 218 - "Total number of items in the connection", 219 - fn(ctx) { 220 - case ctx.data { 221 - Some(value.Object(fields)) -> { 222 - case list.key_find(fields, "totalCount") { 223 - Ok(val) -> Ok(val) 224 - Error(_) -> Ok(value.Null) 225 - } 226 - } 227 - _ -> Ok(value.Null) 228 - } 229 - }, 230 - ), 231 - ], 232 - ) 233 - } 234 - 235 - /// Standard pagination arguments for forward pagination 236 - pub fn forward_pagination_args() -> List(schema.Argument) { 237 - [ 238 - schema.argument( 239 - "first", 240 - schema.int_type(), 241 - "Returns the first n items from the list", 242 - None, 243 - ), 244 - schema.argument( 245 - "after", 246 - schema.string_type(), 247 - "Returns items after the given cursor", 248 - None, 249 - ), 250 - ] 251 - } 252 - 253 - /// Standard pagination arguments for backward pagination 254 - pub fn backward_pagination_args() -> List(schema.Argument) { 255 - [ 256 - schema.argument( 257 - "last", 258 - schema.int_type(), 259 - "Returns the last n items from the list", 260 - None, 261 - ), 262 - schema.argument( 263 - "before", 264 - schema.string_type(), 265 - "Returns items before the given cursor", 266 - None, 267 - ), 268 - ] 269 - } 270 - 271 - /// All standard connection arguments (forward + backward) 272 - /// Note: sortBy is not included yet as it requires InputObject type support 273 - pub fn connection_args() -> List(schema.Argument) { 274 - list.flatten([forward_pagination_args(), backward_pagination_args()]) 275 - } 276 - 277 - /// Converts a PageInfo value to a GraphQL value 278 - pub fn page_info_to_value(page_info: PageInfo) -> value.Value { 279 - value.Object([ 280 - #("hasNextPage", value.Boolean(page_info.has_next_page)), 281 - #("hasPreviousPage", value.Boolean(page_info.has_previous_page)), 282 - #("startCursor", case page_info.start_cursor { 283 - Some(cursor) -> value.String(cursor) 284 - None -> value.Null 285 - }), 286 - #("endCursor", case page_info.end_cursor { 287 - Some(cursor) -> value.String(cursor) 288 - None -> value.Null 289 - }), 290 - ]) 291 - } 292 - 293 - /// Converts an Edge to a GraphQL value 294 - pub fn edge_to_value(edge: Edge(value.Value)) -> value.Value { 295 - value.Object([ 296 - #("node", edge.node), 297 - #("cursor", value.String(edge.cursor)), 298 - ]) 299 - } 300 - 301 - /// Converts a Connection to a GraphQL value 302 - pub fn connection_to_value(connection: Connection(value.Value)) -> value.Value { 303 - let edges_value = 304 - connection.edges 305 - |> list.map(edge_to_value) 306 - |> value.List 307 - 308 - let total_count_value = case connection.total_count { 309 - Some(count) -> value.Int(count) 310 - None -> value.Null 311 - } 312 - 313 - value.Object([ 314 - #("edges", edges_value), 315 - #("pageInfo", page_info_to_value(connection.page_info)), 316 - #("totalCount", total_count_value), 317 - ]) 318 - }
···
-927
graphql/src/graphql/executor.gleam
··· 1 - /// GraphQL Executor 2 - /// 3 - /// Executes GraphQL queries against a schema 4 - import gleam/dict.{type Dict} 5 - import gleam/list 6 - import gleam/option.{None, Some} 7 - import gleam/set.{type Set} 8 - import graphql/introspection 9 - import graphql/parser 10 - import graphql/schema 11 - import graphql/value 12 - 13 - /// GraphQL Error 14 - pub type GraphQLError { 15 - GraphQLError(message: String, path: List(String)) 16 - } 17 - 18 - /// GraphQL Response 19 - pub type Response { 20 - Response(data: value.Value, errors: List(GraphQLError)) 21 - } 22 - 23 - /// Get the response key for a field (alias if present, otherwise field name) 24 - fn response_key(field_name: String, alias: option.Option(String)) -> String { 25 - case alias { 26 - option.Some(alias_name) -> alias_name 27 - option.None -> field_name 28 - } 29 - } 30 - 31 - /// Execute a GraphQL query 32 - pub fn execute( 33 - query: String, 34 - graphql_schema: schema.Schema, 35 - ctx: schema.Context, 36 - ) -> Result(Response, String) { 37 - // Parse the query 38 - case parser.parse(query) { 39 - Error(parse_error) -> 40 - Error("Parse error: " <> format_parse_error(parse_error)) 41 - Ok(document) -> { 42 - // Execute the document 43 - case execute_document(document, graphql_schema, ctx) { 44 - Ok(#(data, errors)) -> Ok(Response(data, errors)) 45 - Error(err) -> Error(err) 46 - } 47 - } 48 - } 49 - } 50 - 51 - fn format_parse_error(err: parser.ParseError) -> String { 52 - case err { 53 - parser.UnexpectedToken(_, msg) -> msg 54 - parser.UnexpectedEndOfInput(msg) -> msg 55 - parser.LexerError(_) -> "Lexer error" 56 - } 57 - } 58 - 59 - /// Execute a document 60 - fn execute_document( 61 - document: parser.Document, 62 - graphql_schema: schema.Schema, 63 - ctx: schema.Context, 64 - ) -> Result(#(value.Value, List(GraphQLError)), String) { 65 - case document { 66 - parser.Document(operations) -> { 67 - // Separate fragments from executable operations 68 - let #(fragments, executable_ops) = partition_operations(operations) 69 - 70 - // Build fragments dictionary 71 - let fragments_dict = build_fragments_dict(fragments) 72 - 73 - // Execute the first executable operation 74 - case executable_ops { 75 - [operation, ..] -> 76 - execute_operation(operation, graphql_schema, ctx, fragments_dict) 77 - [] -> Error("No executable operations in document") 78 - } 79 - } 80 - } 81 - } 82 - 83 - /// Partition operations into fragments and executable operations 84 - fn partition_operations( 85 - operations: List(parser.Operation), 86 - ) -> #(List(parser.Operation), List(parser.Operation)) { 87 - list.partition(operations, fn(op) { 88 - case op { 89 - parser.FragmentDefinition(_, _, _) -> True 90 - _ -> False 91 - } 92 - }) 93 - } 94 - 95 - /// Build a dictionary of fragments keyed by name 96 - fn build_fragments_dict( 97 - fragments: List(parser.Operation), 98 - ) -> Dict(String, parser.Operation) { 99 - fragments 100 - |> list.filter_map(fn(frag) { 101 - case frag { 102 - parser.FragmentDefinition(name, _, _) -> Ok(#(name, frag)) 103 - _ -> Error(Nil) 104 - } 105 - }) 106 - |> dict.from_list 107 - } 108 - 109 - /// Execute an operation 110 - fn execute_operation( 111 - operation: parser.Operation, 112 - graphql_schema: schema.Schema, 113 - ctx: schema.Context, 114 - fragments: Dict(String, parser.Operation), 115 - ) -> Result(#(value.Value, List(GraphQLError)), String) { 116 - case operation { 117 - parser.Query(selection_set) -> { 118 - let root_type = schema.query_type(graphql_schema) 119 - execute_selection_set( 120 - selection_set, 121 - root_type, 122 - graphql_schema, 123 - ctx, 124 - fragments, 125 - [], 126 - ) 127 - } 128 - parser.NamedQuery(_, _, selection_set) -> { 129 - let root_type = schema.query_type(graphql_schema) 130 - execute_selection_set( 131 - selection_set, 132 - root_type, 133 - graphql_schema, 134 - ctx, 135 - fragments, 136 - [], 137 - ) 138 - } 139 - parser.Mutation(selection_set) -> { 140 - // Get mutation root type from schema 141 - case schema.get_mutation_type(graphql_schema) { 142 - option.Some(mutation_type) -> 143 - execute_selection_set( 144 - selection_set, 145 - mutation_type, 146 - graphql_schema, 147 - ctx, 148 - fragments, 149 - [], 150 - ) 151 - option.None -> Error("Schema does not define a mutation type") 152 - } 153 - } 154 - parser.NamedMutation(_, _, selection_set) -> { 155 - // Get mutation root type from schema 156 - case schema.get_mutation_type(graphql_schema) { 157 - option.Some(mutation_type) -> 158 - execute_selection_set( 159 - selection_set, 160 - mutation_type, 161 - graphql_schema, 162 - ctx, 163 - fragments, 164 - [], 165 - ) 166 - option.None -> Error("Schema does not define a mutation type") 167 - } 168 - } 169 - parser.Subscription(selection_set) -> { 170 - // Get subscription root type from schema 171 - case schema.get_subscription_type(graphql_schema) { 172 - option.Some(subscription_type) -> 173 - execute_selection_set( 174 - selection_set, 175 - subscription_type, 176 - graphql_schema, 177 - ctx, 178 - fragments, 179 - [], 180 - ) 181 - option.None -> Error("Schema does not define a subscription type") 182 - } 183 - } 184 - parser.NamedSubscription(_, _, selection_set) -> { 185 - // Get subscription root type from schema 186 - case schema.get_subscription_type(graphql_schema) { 187 - option.Some(subscription_type) -> 188 - execute_selection_set( 189 - selection_set, 190 - subscription_type, 191 - graphql_schema, 192 - ctx, 193 - fragments, 194 - [], 195 - ) 196 - option.None -> Error("Schema does not define a subscription type") 197 - } 198 - } 199 - parser.FragmentDefinition(_, _, _) -> 200 - Error("Fragment definitions are not executable operations") 201 - } 202 - } 203 - 204 - /// Execute a selection set 205 - fn execute_selection_set( 206 - selection_set: parser.SelectionSet, 207 - parent_type: schema.Type, 208 - graphql_schema: schema.Schema, 209 - ctx: schema.Context, 210 - fragments: Dict(String, parser.Operation), 211 - path: List(String), 212 - ) -> Result(#(value.Value, List(GraphQLError)), String) { 213 - case selection_set { 214 - parser.SelectionSet(selections) -> { 215 - let results = 216 - list.map(selections, fn(selection) { 217 - execute_selection( 218 - selection, 219 - parent_type, 220 - graphql_schema, 221 - ctx, 222 - fragments, 223 - path, 224 - ) 225 - }) 226 - 227 - // Collect all data and errors, merging fragment fields 228 - let #(data, errors) = collect_and_merge_fields(results) 229 - 230 - Ok(#(value.Object(data), errors)) 231 - } 232 - } 233 - } 234 - 235 - /// Collect and merge fields from selection results, handling fragment fields 236 - fn collect_and_merge_fields( 237 - results: List(Result(#(String, value.Value, List(GraphQLError)), String)), 238 - ) -> #(List(#(String, value.Value)), List(GraphQLError)) { 239 - let #(data, errors) = 240 - results 241 - |> list.fold(#([], []), fn(acc, r) { 242 - let #(fields_acc, errors_acc) = acc 243 - case r { 244 - Ok(#("__fragment_fields", value.Object(fragment_fields), errs)) -> { 245 - // Merge fragment fields into parent 246 - #( 247 - list.append(fields_acc, fragment_fields), 248 - list.append(errors_acc, errs), 249 - ) 250 - } 251 - Ok(#("__fragment_skip", _, _errs)) -> { 252 - // Skip fragment that didn't match type condition 253 - acc 254 - } 255 - Ok(#(name, val, errs)) -> { 256 - // Regular field 257 - #( 258 - list.append(fields_acc, [#(name, val)]), 259 - list.append(errors_acc, errs), 260 - ) 261 - } 262 - Error(_) -> acc 263 - } 264 - }) 265 - 266 - #(data, errors) 267 - } 268 - 269 - /// Execute a selection 270 - fn execute_selection( 271 - selection: parser.Selection, 272 - parent_type: schema.Type, 273 - graphql_schema: schema.Schema, 274 - ctx: schema.Context, 275 - fragments: Dict(String, parser.Operation), 276 - path: List(String), 277 - ) -> Result(#(String, value.Value, List(GraphQLError)), String) { 278 - case selection { 279 - parser.FragmentSpread(name) -> { 280 - // Look up the fragment definition 281 - case dict.get(fragments, name) { 282 - Error(_) -> Error("Fragment '" <> name <> "' not found") 283 - Ok(parser.FragmentDefinition( 284 - _fname, 285 - type_condition, 286 - fragment_selection_set, 287 - )) -> { 288 - // Check type condition 289 - let current_type_name = schema.type_name(parent_type) 290 - case type_condition == current_type_name { 291 - False -> { 292 - // Type condition doesn't match, skip this fragment 293 - // Return empty object as a placeholder that will be filtered out 294 - Ok(#("__fragment_skip", value.Null, [])) 295 - } 296 - True -> { 297 - // Type condition matches, execute fragment's selections 298 - case 299 - execute_selection_set( 300 - fragment_selection_set, 301 - parent_type, 302 - graphql_schema, 303 - ctx, 304 - fragments, 305 - path, 306 - ) 307 - { 308 - Ok(#(value.Object(fields), errs)) -> { 309 - // Fragment selections should be merged into parent 310 - // For now, return as a special marker 311 - Ok(#("__fragment_fields", value.Object(fields), errs)) 312 - } 313 - Ok(#(val, errs)) -> Ok(#("__fragment_fields", val, errs)) 314 - Error(err) -> Error(err) 315 - } 316 - } 317 - } 318 - } 319 - Ok(_) -> Error("Invalid fragment definition") 320 - } 321 - } 322 - parser.InlineFragment(type_condition_opt, inline_selections) -> { 323 - // Check type condition if present 324 - let current_type_name = schema.type_name(parent_type) 325 - let should_execute = case type_condition_opt { 326 - None -> True 327 - Some(type_condition) -> type_condition == current_type_name 328 - } 329 - 330 - case should_execute { 331 - False -> Ok(#("__fragment_skip", value.Null, [])) 332 - True -> { 333 - let inline_selection_set = parser.SelectionSet(inline_selections) 334 - case 335 - execute_selection_set( 336 - inline_selection_set, 337 - parent_type, 338 - graphql_schema, 339 - ctx, 340 - fragments, 341 - path, 342 - ) 343 - { 344 - Ok(#(value.Object(fields), errs)) -> 345 - Ok(#("__fragment_fields", value.Object(fields), errs)) 346 - Ok(#(val, errs)) -> Ok(#("__fragment_fields", val, errs)) 347 - Error(err) -> Error(err) 348 - } 349 - } 350 - } 351 - } 352 - parser.Field(name, alias, arguments, nested_selections) -> { 353 - // Convert arguments to dict (with variable resolution from context) 354 - let args_dict = arguments_to_dict(arguments, ctx) 355 - 356 - // Determine the response key (use alias if provided, otherwise field name) 357 - let key = response_key(name, alias) 358 - 359 - // Handle introspection meta-fields 360 - case name { 361 - "__typename" -> { 362 - let type_name = schema.type_name(parent_type) 363 - Ok(#(key, value.String(type_name), [])) 364 - } 365 - "__schema" -> { 366 - let schema_value = introspection.schema_introspection(graphql_schema) 367 - // Handle nested selections on __schema 368 - case nested_selections { 369 - [] -> Ok(#(key, schema_value, [])) 370 - _ -> { 371 - let selection_set = parser.SelectionSet(nested_selections) 372 - // We don't have an actual type for __Schema, so we'll handle it specially 373 - // For now, just return the schema value with nested execution 374 - case 375 - execute_introspection_selection_set( 376 - selection_set, 377 - schema_value, 378 - graphql_schema, 379 - ctx, 380 - fragments, 381 - ["__schema", ..path], 382 - set.new(), 383 - ) 384 - { 385 - Ok(#(nested_data, nested_errors)) -> 386 - Ok(#(key, nested_data, nested_errors)) 387 - Error(err) -> { 388 - let error = GraphQLError(err, ["__schema", ..path]) 389 - Ok(#(key, value.Null, [error])) 390 - } 391 - } 392 - } 393 - } 394 - } 395 - "__type" -> { 396 - // Extract the "name" argument 397 - case dict.get(args_dict, "name") { 398 - Ok(value.String(type_name)) -> { 399 - // Look up the type in the schema 400 - case 401 - introspection.type_by_name_introspection( 402 - graphql_schema, 403 - type_name, 404 - ) 405 - { 406 - option.Some(type_value) -> { 407 - // Handle nested selections on __type 408 - case nested_selections { 409 - [] -> Ok(#(key, type_value, [])) 410 - _ -> { 411 - let selection_set = parser.SelectionSet(nested_selections) 412 - case 413 - execute_introspection_selection_set( 414 - selection_set, 415 - type_value, 416 - graphql_schema, 417 - ctx, 418 - fragments, 419 - ["__type", ..path], 420 - set.new(), 421 - ) 422 - { 423 - Ok(#(nested_data, nested_errors)) -> 424 - Ok(#(key, nested_data, nested_errors)) 425 - Error(err) -> { 426 - let error = GraphQLError(err, ["__type", ..path]) 427 - Ok(#(key, value.Null, [error])) 428 - } 429 - } 430 - } 431 - } 432 - } 433 - option.None -> { 434 - // Type not found, return null (per GraphQL spec) 435 - Ok(#(key, value.Null, [])) 436 - } 437 - } 438 - } 439 - Ok(_) -> { 440 - let error = 441 - GraphQLError("__type argument 'name' must be a String", path) 442 - Ok(#(key, value.Null, [error])) 443 - } 444 - Error(_) -> { 445 - let error = 446 - GraphQLError("__type requires a 'name' argument", path) 447 - Ok(#(key, value.Null, [error])) 448 - } 449 - } 450 - } 451 - _ -> { 452 - // Get field from schema 453 - case schema.get_field(parent_type, name) { 454 - None -> { 455 - let error = GraphQLError("Field '" <> name <> "' not found", path) 456 - Ok(#(key, value.Null, [error])) 457 - } 458 - Some(field) -> { 459 - // Get the field's type for nested selections 460 - let field_type_def = schema.field_type(field) 461 - 462 - // Create context with arguments (preserve variables from parent context) 463 - let field_ctx = schema.Context(ctx.data, args_dict, ctx.variables) 464 - 465 - // Resolve the field 466 - case schema.resolve_field(field, field_ctx) { 467 - Error(err) -> { 468 - let error = GraphQLError(err, [name, ..path]) 469 - Ok(#(key, value.Null, [error])) 470 - } 471 - Ok(field_value) -> { 472 - // If there are nested selections, recurse 473 - case nested_selections { 474 - [] -> Ok(#(key, field_value, [])) 475 - _ -> { 476 - // Need to resolve nested fields 477 - case field_value { 478 - value.Object(_) -> { 479 - // Check if field_type_def is a union type 480 - // If so, resolve it to the concrete type first 481 - let type_to_use = case 482 - schema.is_union(field_type_def) 483 - { 484 - True -> { 485 - // Create context with the field value for type resolution 486 - let resolve_ctx = 487 - schema.context(option.Some(field_value)) 488 - case 489 - schema.resolve_union_type( 490 - field_type_def, 491 - resolve_ctx, 492 - ) 493 - { 494 - Ok(concrete_type) -> concrete_type 495 - Error(_) -> field_type_def 496 - // Fallback to union type if resolution fails 497 - } 498 - } 499 - False -> field_type_def 500 - } 501 - 502 - // Execute nested selections using the resolved type 503 - // Create new context with this object's data 504 - let object_ctx = 505 - schema.context(option.Some(field_value)) 506 - let selection_set = 507 - parser.SelectionSet(nested_selections) 508 - case 509 - execute_selection_set( 510 - selection_set, 511 - type_to_use, 512 - graphql_schema, 513 - object_ctx, 514 - fragments, 515 - [name, ..path], 516 - ) 517 - { 518 - Ok(#(nested_data, nested_errors)) -> 519 - Ok(#(key, nested_data, nested_errors)) 520 - Error(err) -> { 521 - let error = GraphQLError(err, [name, ..path]) 522 - Ok(#(key, value.Null, [error])) 523 - } 524 - } 525 - } 526 - value.List(items) -> { 527 - // Handle list with nested selections 528 - // Get the inner type from the LIST wrapper, unwrapping NonNull if needed 529 - let inner_type = case 530 - schema.inner_type(field_type_def) 531 - { 532 - option.Some(t) -> { 533 - // If the result is still wrapped (NonNull), unwrap it too 534 - case schema.inner_type(t) { 535 - option.Some(unwrapped) -> unwrapped 536 - option.None -> t 537 - } 538 - } 539 - option.None -> field_type_def 540 - } 541 - 542 - // Execute nested selections on each item 543 - let selection_set = 544 - parser.SelectionSet(nested_selections) 545 - let results = 546 - list.map(items, fn(item) { 547 - // Check if inner_type is a union and resolve it 548 - let item_type = case schema.is_union(inner_type) { 549 - True -> { 550 - // Create context with the item value for type resolution 551 - let resolve_ctx = 552 - schema.context(option.Some(item)) 553 - case 554 - schema.resolve_union_type( 555 - inner_type, 556 - resolve_ctx, 557 - ) 558 - { 559 - Ok(concrete_type) -> concrete_type 560 - Error(_) -> inner_type 561 - // Fallback to union type if resolution fails 562 - } 563 - } 564 - False -> inner_type 565 - } 566 - 567 - // Create context with this item's data 568 - let item_ctx = schema.context(option.Some(item)) 569 - execute_selection_set( 570 - selection_set, 571 - item_type, 572 - graphql_schema, 573 - item_ctx, 574 - fragments, 575 - [name, ..path], 576 - ) 577 - }) 578 - 579 - // Collect results and errors 580 - let processed_items = 581 - results 582 - |> list.filter_map(fn(r) { 583 - case r { 584 - Ok(#(val, _)) -> Ok(val) 585 - Error(_) -> Error(Nil) 586 - } 587 - }) 588 - 589 - let all_errors = 590 - results 591 - |> list.flat_map(fn(r) { 592 - case r { 593 - Ok(#(_, errs)) -> errs 594 - Error(_) -> [] 595 - } 596 - }) 597 - 598 - Ok(#(key, value.List(processed_items), all_errors)) 599 - } 600 - _ -> Ok(#(key, field_value, [])) 601 - } 602 - } 603 - } 604 - } 605 - } 606 - } 607 - } 608 - } 609 - } 610 - } 611 - } 612 - } 613 - 614 - /// Execute a selection set on an introspection value (like __schema) 615 - /// This directly reads fields from the value.Object rather than using resolvers 616 - fn execute_introspection_selection_set( 617 - selection_set: parser.SelectionSet, 618 - value_obj: value.Value, 619 - graphql_schema: schema.Schema, 620 - ctx: schema.Context, 621 - fragments: Dict(String, parser.Operation), 622 - path: List(String), 623 - visited_types: Set(String), 624 - ) -> Result(#(value.Value, List(GraphQLError)), String) { 625 - case selection_set { 626 - parser.SelectionSet(selections) -> { 627 - case value_obj { 628 - value.List(items) -> { 629 - // For lists, execute the selection set on each item 630 - let results = 631 - list.map(items, fn(item) { 632 - execute_introspection_selection_set( 633 - selection_set, 634 - item, 635 - graphql_schema, 636 - ctx, 637 - fragments, 638 - path, 639 - visited_types, 640 - ) 641 - }) 642 - 643 - // Collect the data and errors 644 - let data_items = 645 - results 646 - |> list.filter_map(fn(r) { 647 - case r { 648 - Ok(#(val, _)) -> Ok(val) 649 - Error(_) -> Error(Nil) 650 - } 651 - }) 652 - 653 - let all_errors = 654 - results 655 - |> list.flat_map(fn(r) { 656 - case r { 657 - Ok(#(_, errs)) -> errs 658 - Error(_) -> [] 659 - } 660 - }) 661 - 662 - Ok(#(value.List(data_items), all_errors)) 663 - } 664 - value.Null -> { 665 - // If the value is null, just return null regardless of selections 666 - // This handles cases like mutationType and subscriptionType which are null 667 - Ok(#(value.Null, [])) 668 - } 669 - value.Object(fields) -> { 670 - // CYCLE DETECTION: Extract type name from object to detect circular references 671 - let type_name = case list.key_find(fields, "name") { 672 - Ok(value.String(name)) -> option.Some(name) 673 - _ -> option.None 674 - } 675 - 676 - // Check if we've already visited this type to prevent infinite loops 677 - let is_cycle = case type_name { 678 - option.Some(name) -> set.contains(visited_types, name) 679 - option.None -> False 680 - } 681 - 682 - // If we detected a cycle, return a minimal object to break the loop 683 - case is_cycle { 684 - True -> { 685 - // Return just the type name and kind to break the cycle 686 - let minimal_fields = case type_name { 687 - option.Some(name) -> { 688 - let kind_value = case list.key_find(fields, "kind") { 689 - Ok(kind) -> kind 690 - Error(_) -> value.Null 691 - } 692 - [#("name", value.String(name)), #("kind", kind_value)] 693 - } 694 - option.None -> [] 695 - } 696 - Ok(#(value.Object(minimal_fields), [])) 697 - } 698 - False -> { 699 - // Add current type to visited set before recursing 700 - let new_visited = case type_name { 701 - option.Some(name) -> set.insert(visited_types, name) 702 - option.None -> visited_types 703 - } 704 - 705 - // For each selection, find the corresponding field in the object 706 - let results = 707 - list.map(selections, fn(selection) { 708 - case selection { 709 - parser.FragmentSpread(name) -> { 710 - // Look up the fragment definition 711 - case dict.get(fragments, name) { 712 - Error(_) -> { 713 - // Fragment not found - return error 714 - let error = 715 - GraphQLError( 716 - "Fragment '" <> name <> "' not found", 717 - path, 718 - ) 719 - Ok( 720 - #( 721 - "__FRAGMENT_ERROR", 722 - value.String("Fragment not found: " <> name), 723 - [error], 724 - ), 725 - ) 726 - } 727 - Ok(parser.FragmentDefinition( 728 - _fname, 729 - _type_condition, 730 - fragment_selection_set, 731 - )) -> { 732 - // For introspection, we don't check type conditions - just execute the fragment 733 - // IMPORTANT: Use visited_types (not new_visited) because we're selecting from 734 - // the SAME object, not recursing into it. The current object was already added 735 - // to new_visited, but the fragment is just selecting different fields. 736 - case 737 - execute_introspection_selection_set( 738 - fragment_selection_set, 739 - value_obj, 740 - graphql_schema, 741 - ctx, 742 - fragments, 743 - path, 744 - visited_types, 745 - ) 746 - { 747 - Ok(#(value.Object(fragment_fields), errs)) -> 748 - Ok(#( 749 - "__fragment_fields", 750 - value.Object(fragment_fields), 751 - errs, 752 - )) 753 - Ok(#(val, errs)) -> 754 - Ok(#("__fragment_fields", val, errs)) 755 - Error(_err) -> Error(Nil) 756 - } 757 - } 758 - Ok(_) -> Error(Nil) 759 - // Invalid fragment definition 760 - } 761 - } 762 - parser.InlineFragment( 763 - _type_condition_opt, 764 - inline_selections, 765 - ) -> { 766 - // For introspection, inline fragments always execute (no type checking needed) 767 - // Execute the inline fragment's selections on this object 768 - let inline_selection_set = 769 - parser.SelectionSet(inline_selections) 770 - case 771 - execute_introspection_selection_set( 772 - inline_selection_set, 773 - value_obj, 774 - graphql_schema, 775 - ctx, 776 - fragments, 777 - path, 778 - new_visited, 779 - ) 780 - { 781 - Ok(#(value.Object(fragment_fields), errs)) -> 782 - // Return fragment fields to be merged 783 - Ok(#( 784 - "__fragment_fields", 785 - value.Object(fragment_fields), 786 - errs, 787 - )) 788 - Ok(#(val, errs)) -> 789 - Ok(#("__fragment_fields", val, errs)) 790 - Error(_err) -> Error(Nil) 791 - } 792 - } 793 - parser.Field(name, alias, _arguments, nested_selections) -> { 794 - // Determine the response key (use alias if provided, otherwise field name) 795 - let key = response_key(name, alias) 796 - 797 - // Find the field in the object 798 - case list.key_find(fields, name) { 799 - Ok(field_value) -> { 800 - // Handle nested selections 801 - case nested_selections { 802 - [] -> Ok(#(key, field_value, [])) 803 - _ -> { 804 - let selection_set = 805 - parser.SelectionSet(nested_selections) 806 - case 807 - execute_introspection_selection_set( 808 - selection_set, 809 - field_value, 810 - graphql_schema, 811 - ctx, 812 - fragments, 813 - [name, ..path], 814 - new_visited, 815 - ) 816 - { 817 - Ok(#(nested_data, nested_errors)) -> 818 - Ok(#(key, nested_data, nested_errors)) 819 - Error(err) -> { 820 - let error = GraphQLError(err, [name, ..path]) 821 - Ok(#(key, value.Null, [error])) 822 - } 823 - } 824 - } 825 - } 826 - } 827 - Error(_) -> { 828 - let error = 829 - GraphQLError( 830 - "Field '" <> name <> "' not found", 831 - path, 832 - ) 833 - Ok(#(key, value.Null, [error])) 834 - } 835 - } 836 - } 837 - } 838 - }) 839 - 840 - // Collect all data and errors, merging fragment fields 841 - let #(data, errors) = 842 - results 843 - |> list.fold(#([], []), fn(acc, r) { 844 - let #(fields_acc, errors_acc) = acc 845 - case r { 846 - Ok(#( 847 - "__fragment_fields", 848 - value.Object(fragment_fields), 849 - errs, 850 - )) -> { 851 - // Merge fragment fields into parent 852 - #( 853 - list.append(fields_acc, fragment_fields), 854 - list.append(errors_acc, errs), 855 - ) 856 - } 857 - Ok(#(name, val, errs)) -> { 858 - // Regular field 859 - #( 860 - list.append(fields_acc, [#(name, val)]), 861 - list.append(errors_acc, errs), 862 - ) 863 - } 864 - Error(_) -> acc 865 - } 866 - }) 867 - 868 - Ok(#(value.Object(data), errors)) 869 - } 870 - } 871 - } 872 - _ -> 873 - Error( 874 - "Expected object, list, or null for introspection selection set", 875 - ) 876 - } 877 - } 878 - } 879 - } 880 - 881 - /// Convert parser ArgumentValue to value.Value 882 - fn argument_value_to_value( 883 - arg_value: parser.ArgumentValue, 884 - ctx: schema.Context, 885 - ) -> value.Value { 886 - case arg_value { 887 - parser.IntValue(s) -> value.String(s) 888 - parser.FloatValue(s) -> value.String(s) 889 - parser.StringValue(s) -> value.String(s) 890 - parser.BooleanValue(b) -> value.Boolean(b) 891 - parser.NullValue -> value.Null 892 - parser.EnumValue(s) -> value.String(s) 893 - parser.ListValue(items) -> 894 - value.List( 895 - list.map(items, fn(item) { argument_value_to_value(item, ctx) }), 896 - ) 897 - parser.ObjectValue(fields) -> 898 - value.Object( 899 - list.map(fields, fn(pair) { 900 - let #(name, val) = pair 901 - #(name, argument_value_to_value(val, ctx)) 902 - }), 903 - ) 904 - parser.VariableValue(name) -> { 905 - // Look up variable value from context 906 - case schema.get_variable(ctx, name) { 907 - option.Some(val) -> val 908 - option.None -> value.Null 909 - } 910 - } 911 - } 912 - } 913 - 914 - /// Convert list of Arguments to a Dict of values 915 - fn arguments_to_dict( 916 - arguments: List(parser.Argument), 917 - ctx: schema.Context, 918 - ) -> Dict(String, value.Value) { 919 - list.fold(arguments, dict.new(), fn(acc, arg) { 920 - case arg { 921 - parser.Argument(name, arg_value) -> { 922 - let value = argument_value_to_value(arg_value, ctx) 923 - dict.insert(acc, name, value) 924 - } 925 - } 926 - }) 927 - }
···
-424
graphql/src/graphql/introspection.gleam
··· 1 - /// GraphQL Introspection 2 - /// 3 - /// Implements the GraphQL introspection system per the GraphQL spec. 4 - /// Provides __schema, __type, and __typename meta-fields. 5 - import gleam/dict 6 - import gleam/list 7 - import gleam/option 8 - import gleam/result 9 - import graphql/schema 10 - import graphql/value 11 - 12 - /// Build introspection value for __schema 13 - pub fn schema_introspection(graphql_schema: schema.Schema) -> value.Value { 14 - let query_type = schema.query_type(graphql_schema) 15 - let mutation_type_option = schema.get_mutation_type(graphql_schema) 16 - let subscription_type_option = schema.get_subscription_type(graphql_schema) 17 - 18 - // Build list of all types in the schema 19 - let all_types = get_all_types(graphql_schema) 20 - 21 - // Build mutation type ref if it exists 22 - let mutation_type_value = case mutation_type_option { 23 - option.Some(mutation_type) -> type_ref(mutation_type) 24 - option.None -> value.Null 25 - } 26 - 27 - // Build subscription type ref if it exists 28 - let subscription_type_value = case subscription_type_option { 29 - option.Some(subscription_type) -> type_ref(subscription_type) 30 - option.None -> value.Null 31 - } 32 - 33 - value.Object([ 34 - #("queryType", type_ref(query_type)), 35 - #("mutationType", mutation_type_value), 36 - #("subscriptionType", subscription_type_value), 37 - #("types", value.List(all_types)), 38 - #("directives", value.List([])), 39 - ]) 40 - } 41 - 42 - /// Build introspection value for __type(name: "TypeName") 43 - /// Returns Some(type_introspection) if the type is found, None otherwise 44 - pub fn type_by_name_introspection( 45 - graphql_schema: schema.Schema, 46 - type_name: String, 47 - ) -> option.Option(value.Value) { 48 - let all_types = get_all_schema_types(graphql_schema) 49 - 50 - // Find the type with the matching name 51 - let found_type = 52 - list.find(all_types, fn(t) { schema.type_name(t) == type_name }) 53 - 54 - case found_type { 55 - Ok(t) -> option.Some(type_introspection(t)) 56 - Error(_) -> option.None 57 - } 58 - } 59 - 60 - /// Get all types from the schema as schema.Type values 61 - /// Useful for testing and documentation generation 62 - pub fn get_all_schema_types(graphql_schema: schema.Schema) -> List(schema.Type) { 63 - let query_type = schema.query_type(graphql_schema) 64 - let mutation_type_option = schema.get_mutation_type(graphql_schema) 65 - let subscription_type_option = schema.get_subscription_type(graphql_schema) 66 - 67 - // Collect all types by traversing the query type 68 - let mut_collected_types = collect_types_from_type(query_type, []) 69 - 70 - // Also collect types from mutation type if it exists 71 - let mutation_collected_types = case mutation_type_option { 72 - option.Some(mutation_type) -> 73 - collect_types_from_type(mutation_type, mut_collected_types) 74 - option.None -> mut_collected_types 75 - } 76 - 77 - // Also collect types from subscription type if it exists 78 - let all_collected_types = case subscription_type_option { 79 - option.Some(subscription_type) -> 80 - collect_types_from_type(subscription_type, mutation_collected_types) 81 - option.None -> mutation_collected_types 82 - } 83 - 84 - // Deduplicate by type name, preferring types with more fields 85 - // This ensures we get the "most complete" version of each type 86 - let unique_types = deduplicate_types_by_name(all_collected_types) 87 - 88 - // Add any built-in scalars that aren't already in the list 89 - let all_built_ins = [ 90 - schema.string_type(), 91 - schema.int_type(), 92 - schema.float_type(), 93 - schema.boolean_type(), 94 - schema.id_type(), 95 - ] 96 - 97 - let collected_names = list.map(unique_types, schema.type_name) 98 - let missing_built_ins = 99 - list.filter(all_built_ins, fn(built_in) { 100 - let built_in_name = schema.type_name(built_in) 101 - !list.contains(collected_names, built_in_name) 102 - }) 103 - 104 - list.append(unique_types, missing_built_ins) 105 - } 106 - 107 - /// Get all types from the schema 108 - fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) { 109 - let all_types = get_all_schema_types(graphql_schema) 110 - 111 - // Convert all types to introspection values 112 - list.map(all_types, type_introspection) 113 - } 114 - 115 - /// Deduplicate types by name, keeping the version with the most fields 116 - /// This ensures we get the "most complete" version of each type when 117 - /// multiple versions exist (e.g., from different passes in schema building) 118 - fn deduplicate_types_by_name(types: List(schema.Type)) -> List(schema.Type) { 119 - // Group types by name 120 - types 121 - |> list.group(schema.type_name) 122 - |> dict.to_list 123 - |> list.map(fn(pair) { 124 - let #(_name, type_list) = pair 125 - // For each group, find the type with the most content 126 - type_list 127 - |> list.reduce(fn(best, current) { 128 - // Count content: fields for object types, enum values for enums, etc. 129 - let best_content_count = get_type_content_count(best) 130 - let current_content_count = get_type_content_count(current) 131 - 132 - // Prefer the type with more content 133 - case current_content_count > best_content_count { 134 - True -> current 135 - False -> best 136 - } 137 - }) 138 - |> result.unwrap( 139 - list.first(type_list) 140 - |> result.unwrap(schema.string_type()), 141 - ) 142 - }) 143 - } 144 - 145 - /// Get the "content count" for a type (fields, enum values, input fields, etc.) 146 - /// This helps us pick the most complete version of a type during deduplication 147 - fn get_type_content_count(t: schema.Type) -> Int { 148 - // For object types, count fields 149 - let field_count = list.length(schema.get_fields(t)) 150 - 151 - // For enum types, count enum values 152 - let enum_value_count = list.length(schema.get_enum_values(t)) 153 - 154 - // For input object types, count input fields 155 - let input_field_count = list.length(schema.get_input_fields(t)) 156 - 157 - // Return the maximum (types will only have one of these be non-zero) 158 - [field_count, enum_value_count, input_field_count] 159 - |> list.reduce(fn(a, b) { 160 - case a > b { 161 - True -> a 162 - False -> b 163 - } 164 - }) 165 - |> result.unwrap(0) 166 - } 167 - 168 - /// Collect all types referenced in a type (recursively) 169 - /// Note: We collect ALL instances of each type (even duplicates by name) 170 - /// because we want to find the "most complete" version during deduplication 171 - fn collect_types_from_type( 172 - t: schema.Type, 173 - acc: List(schema.Type), 174 - ) -> List(schema.Type) { 175 - // Always add this type - we'll deduplicate later by choosing the version with most fields 176 - let new_acc = [t, ..acc] 177 - 178 - // To prevent infinite recursion, check if we've already traversed this exact type instance 179 - // We use a simple heuristic: if this type name appears multiple times AND this specific 180 - // instance has the same or fewer content than what we've seen, skip traversing its children 181 - let should_traverse_children = case 182 - schema.is_object(t) || schema.is_enum(t) || schema.is_union(t) 183 - { 184 - True -> { 185 - let current_content_count = get_type_content_count(t) 186 - let existing_with_same_name = 187 - list.filter(acc, fn(existing) { 188 - schema.type_name(existing) == schema.type_name(t) 189 - }) 190 - let max_existing_content = 191 - existing_with_same_name 192 - |> list.map(get_type_content_count) 193 - |> list.reduce(fn(a, b) { 194 - case a > b { 195 - True -> a 196 - False -> b 197 - } 198 - }) 199 - |> result.unwrap(0) 200 - 201 - // Only traverse if this instance has more content than we've seen before 202 - current_content_count > max_existing_content 203 - } 204 - False -> True 205 - } 206 - 207 - case should_traverse_children { 208 - False -> new_acc 209 - True -> { 210 - // Recursively collect types from fields if this is an object type 211 - case schema.is_object(t) { 212 - True -> { 213 - let fields = schema.get_fields(t) 214 - list.fold(fields, new_acc, fn(acc2, field) { 215 - let field_type = schema.field_type(field) 216 - let acc3 = collect_types_from_type_deep(field_type, acc2) 217 - 218 - // Also collect types from field arguments 219 - let arguments = schema.field_arguments(field) 220 - list.fold(arguments, acc3, fn(acc4, arg) { 221 - let arg_type = schema.argument_type(arg) 222 - collect_types_from_type_deep(arg_type, acc4) 223 - }) 224 - }) 225 - } 226 - False -> { 227 - // Check if it's a union type 228 - case schema.is_union(t) { 229 - True -> { 230 - // Collect types from union's possible_types 231 - let possible_types = schema.get_possible_types(t) 232 - list.fold(possible_types, new_acc, fn(acc2, union_type) { 233 - collect_types_from_type_deep(union_type, acc2) 234 - }) 235 - } 236 - False -> { 237 - // Check if it's an InputObjectType 238 - let input_fields = schema.get_input_fields(t) 239 - case list.is_empty(input_fields) { 240 - False -> { 241 - // This is an InputObjectType, collect types from its fields 242 - list.fold(input_fields, new_acc, fn(acc2, input_field) { 243 - let field_type = schema.input_field_type(input_field) 244 - collect_types_from_type_deep(field_type, acc2) 245 - }) 246 - } 247 - True -> { 248 - // Check if it's a wrapping type (List or NonNull) 249 - case schema.inner_type(t) { 250 - option.Some(inner) -> 251 - collect_types_from_type_deep(inner, new_acc) 252 - option.None -> new_acc 253 - } 254 - } 255 - } 256 - } 257 - } 258 - } 259 - } 260 - } 261 - } 262 - } 263 - 264 - /// Helper to unwrap LIST and NON_NULL and collect the inner type 265 - fn collect_types_from_type_deep( 266 - t: schema.Type, 267 - acc: List(schema.Type), 268 - ) -> List(schema.Type) { 269 - // Check if this is a wrapping type (List or NonNull) 270 - case schema.inner_type(t) { 271 - option.Some(inner) -> collect_types_from_type_deep(inner, acc) 272 - option.None -> collect_types_from_type(t, acc) 273 - } 274 - } 275 - 276 - /// Build full type introspection value 277 - fn type_introspection(t: schema.Type) -> value.Value { 278 - let kind = schema.type_kind(t) 279 - let type_name = schema.type_name(t) 280 - 281 - // Get inner type for LIST and NON_NULL 282 - let of_type = case schema.inner_type(t) { 283 - option.Some(inner) -> type_ref(inner) 284 - option.None -> value.Null 285 - } 286 - 287 - // Determine fields based on kind 288 - let fields = case kind { 289 - "OBJECT" -> value.List(get_fields_for_type(t)) 290 - _ -> value.Null 291 - } 292 - 293 - // Determine inputFields for INPUT_OBJECT types 294 - let input_fields = case kind { 295 - "INPUT_OBJECT" -> value.List(get_input_fields_for_type(t)) 296 - _ -> value.Null 297 - } 298 - 299 - // Determine enumValues for ENUM types 300 - let enum_values = case kind { 301 - "ENUM" -> value.List(get_enum_values_for_type(t)) 302 - _ -> value.Null 303 - } 304 - 305 - // Determine possibleTypes for UNION types 306 - let possible_types = case kind { 307 - "UNION" -> { 308 - let types = schema.get_possible_types(t) 309 - value.List(list.map(types, type_ref)) 310 - } 311 - _ -> value.Null 312 - } 313 - 314 - // Handle wrapping types (LIST/NON_NULL) differently 315 - let name = case kind { 316 - "LIST" -> value.Null 317 - "NON_NULL" -> value.Null 318 - _ -> value.String(type_name) 319 - } 320 - 321 - let description = case schema.type_description(t) { 322 - "" -> value.Null 323 - desc -> value.String(desc) 324 - } 325 - 326 - value.Object([ 327 - #("kind", value.String(kind)), 328 - #("name", name), 329 - #("description", description), 330 - #("fields", fields), 331 - #("interfaces", value.List([])), 332 - #("possibleTypes", possible_types), 333 - #("enumValues", enum_values), 334 - #("inputFields", input_fields), 335 - #("ofType", of_type), 336 - ]) 337 - } 338 - 339 - /// Get fields for a type (if it's an object type) 340 - fn get_fields_for_type(t: schema.Type) -> List(value.Value) { 341 - let fields = schema.get_fields(t) 342 - 343 - list.map(fields, fn(field) { 344 - let field_type_val = schema.field_type(field) 345 - let args = schema.field_arguments(field) 346 - 347 - value.Object([ 348 - #("name", value.String(schema.field_name(field))), 349 - #("description", value.String(schema.field_description(field))), 350 - #("args", value.List(list.map(args, argument_introspection))), 351 - #("type", type_ref(field_type_val)), 352 - #("isDeprecated", value.Boolean(False)), 353 - #("deprecationReason", value.Null), 354 - ]) 355 - }) 356 - } 357 - 358 - /// Get input fields for a type (if it's an input object type) 359 - fn get_input_fields_for_type(t: schema.Type) -> List(value.Value) { 360 - let input_fields = schema.get_input_fields(t) 361 - 362 - list.map(input_fields, fn(input_field) { 363 - let field_type_val = schema.input_field_type(input_field) 364 - 365 - value.Object([ 366 - #("name", value.String(schema.input_field_name(input_field))), 367 - #( 368 - "description", 369 - value.String(schema.input_field_description(input_field)), 370 - ), 371 - #("type", type_ref(field_type_val)), 372 - #("defaultValue", value.Null), 373 - ]) 374 - }) 375 - } 376 - 377 - /// Get enum values for a type (if it's an enum type) 378 - fn get_enum_values_for_type(t: schema.Type) -> List(value.Value) { 379 - let enum_values = schema.get_enum_values(t) 380 - 381 - list.map(enum_values, fn(enum_value) { 382 - value.Object([ 383 - #("name", value.String(schema.enum_value_name(enum_value))), 384 - #("description", value.String(schema.enum_value_description(enum_value))), 385 - #("isDeprecated", value.Boolean(False)), 386 - #("deprecationReason", value.Null), 387 - ]) 388 - }) 389 - } 390 - 391 - /// Build introspection for an argument 392 - fn argument_introspection(arg: schema.Argument) -> value.Value { 393 - value.Object([ 394 - #("name", value.String(schema.argument_name(arg))), 395 - #("description", value.String(schema.argument_description(arg))), 396 - #("type", type_ref(schema.argument_type(arg))), 397 - #("defaultValue", value.Null), 398 - ]) 399 - } 400 - 401 - /// Build a type reference (simplified version of type_introspection for field types) 402 - fn type_ref(t: schema.Type) -> value.Value { 403 - let kind = schema.type_kind(t) 404 - let type_name = schema.type_name(t) 405 - 406 - // Get inner type for LIST and NON_NULL 407 - let of_type = case schema.inner_type(t) { 408 - option.Some(inner) -> type_ref(inner) 409 - option.None -> value.Null 410 - } 411 - 412 - // Handle wrapping types (LIST/NON_NULL) differently 413 - let name = case kind { 414 - "LIST" -> value.Null 415 - "NON_NULL" -> value.Null 416 - _ -> value.String(type_name) 417 - } 418 - 419 - value.Object([ 420 - #("kind", value.String(kind)), 421 - #("name", name), 422 - #("ofType", of_type), 423 - ]) 424 - }
···
-301
graphql/src/graphql/lexer.gleam
··· 1 - /// GraphQL Lexer - Tokenization 2 - /// 3 - /// Per GraphQL spec Section 2 - Language 4 - /// Converts source text into a sequence of lexical tokens 5 - import gleam/list 6 - import gleam/result 7 - import gleam/string 8 - 9 - /// GraphQL token types 10 - pub type Token { 11 - // Punctuators 12 - BraceOpen 13 - BraceClose 14 - ParenOpen 15 - ParenClose 16 - BracketOpen 17 - BracketClose 18 - Colon 19 - Comma 20 - Pipe 21 - Equals 22 - At 23 - Dollar 24 - Exclamation 25 - Spread 26 - 27 - // Values 28 - Name(String) 29 - Int(String) 30 - Float(String) 31 - String(String) 32 - 33 - // Ignored tokens (kept for optional whitespace preservation) 34 - Whitespace 35 - Comment(String) 36 - } 37 - 38 - pub type LexerError { 39 - UnexpectedCharacter(String, Int) 40 - UnterminatedString(Int) 41 - InvalidNumber(String, Int) 42 - } 43 - 44 - /// Tokenize a GraphQL source string into a list of tokens 45 - /// 46 - /// Filters out whitespace and comments by default 47 - pub fn tokenize(source: String) -> Result(List(Token), LexerError) { 48 - source 49 - |> string.to_graphemes 50 - |> tokenize_graphemes([], 0) 51 - |> result.map(filter_ignored) 52 - } 53 - 54 - /// Internal: Tokenize graphemes recursively 55 - fn tokenize_graphemes( 56 - graphemes: List(String), 57 - acc: List(Token), 58 - pos: Int, 59 - ) -> Result(List(Token), LexerError) { 60 - case graphemes { 61 - [] -> Ok(list.reverse(acc)) 62 - 63 - // Whitespace 64 - [" ", ..rest] | ["\t", ..rest] | ["\n", ..rest] | ["\r", ..rest] -> 65 - tokenize_graphemes(rest, [Whitespace, ..acc], pos + 1) 66 - 67 - // Comments 68 - ["#", ..rest] -> { 69 - let #(comment, remaining) = take_until_newline(rest) 70 - tokenize_graphemes(remaining, [Comment(comment), ..acc], pos + 1) 71 - } 72 - 73 - // Punctuators 74 - ["{", ..rest] -> tokenize_graphemes(rest, [BraceOpen, ..acc], pos + 1) 75 - ["}", ..rest] -> tokenize_graphemes(rest, [BraceClose, ..acc], pos + 1) 76 - ["(", ..rest] -> tokenize_graphemes(rest, [ParenOpen, ..acc], pos + 1) 77 - [")", ..rest] -> tokenize_graphemes(rest, [ParenClose, ..acc], pos + 1) 78 - ["[", ..rest] -> tokenize_graphemes(rest, [BracketOpen, ..acc], pos + 1) 79 - ["]", ..rest] -> tokenize_graphemes(rest, [BracketClose, ..acc], pos + 1) 80 - [":", ..rest] -> tokenize_graphemes(rest, [Colon, ..acc], pos + 1) 81 - [",", ..rest] -> tokenize_graphemes(rest, [Comma, ..acc], pos + 1) 82 - ["|", ..rest] -> tokenize_graphemes(rest, [Pipe, ..acc], pos + 1) 83 - ["=", ..rest] -> tokenize_graphemes(rest, [Equals, ..acc], pos + 1) 84 - ["@", ..rest] -> tokenize_graphemes(rest, [At, ..acc], pos + 1) 85 - ["$", ..rest] -> tokenize_graphemes(rest, [Dollar, ..acc], pos + 1) 86 - ["!", ..rest] -> tokenize_graphemes(rest, [Exclamation, ..acc], pos + 1) 87 - 88 - // Spread (...) 89 - [".", ".", ".", ..rest] -> 90 - tokenize_graphemes(rest, [Spread, ..acc], pos + 3) 91 - 92 - // Strings 93 - ["\"", ..rest] -> { 94 - case take_string(rest, []) { 95 - Ok(#(str, remaining)) -> 96 - tokenize_graphemes(remaining, [String(str), ..acc], pos + 1) 97 - Error(err) -> Error(err) 98 - } 99 - } 100 - 101 - // Numbers (Int or Float) - check for minus or digits 102 - ["-", ..] 103 - | ["0", ..] 104 - | ["1", ..] 105 - | ["2", ..] 106 - | ["3", ..] 107 - | ["4", ..] 108 - | ["5", ..] 109 - | ["6", ..] 110 - | ["7", ..] 111 - | ["8", ..] 112 - | ["9", ..] -> { 113 - case take_number(graphemes) { 114 - Ok(#(num_str, is_float, remaining)) -> { 115 - let token = case is_float { 116 - True -> Float(num_str) 117 - False -> Int(num_str) 118 - } 119 - tokenize_graphemes(remaining, [token, ..acc], pos + 1) 120 - } 121 - Error(err) -> Error(err) 122 - } 123 - } 124 - 125 - // Names (identifiers) - must start with letter or underscore 126 - [char, ..] -> { 127 - case is_name_start(char) { 128 - True -> { 129 - let #(name, remaining) = take_name(graphemes) 130 - tokenize_graphemes(remaining, [Name(name), ..acc], pos + 1) 131 - } 132 - False -> Error(UnexpectedCharacter(char, pos)) 133 - } 134 - } 135 - } 136 - } 137 - 138 - /// Take characters until newline 139 - fn take_until_newline(graphemes: List(String)) -> #(String, List(String)) { 140 - let #(chars, rest) = take_while(graphemes, fn(c) { c != "\n" && c != "\r" }) 141 - #(string.concat(chars), rest) 142 - } 143 - 144 - /// Take string contents (handles escapes) 145 - fn take_string( 146 - graphemes: List(String), 147 - acc: List(String), 148 - ) -> Result(#(String, List(String)), LexerError) { 149 - case graphemes { 150 - [] -> Error(UnterminatedString(0)) 151 - 152 - ["\"", ..rest] -> Ok(#(string.concat(list.reverse(acc)), rest)) 153 - 154 - ["\\", "n", ..rest] -> take_string(rest, ["\n", ..acc]) 155 - ["\\", "r", ..rest] -> take_string(rest, ["\r", ..acc]) 156 - ["\\", "t", ..rest] -> take_string(rest, ["\t", ..acc]) 157 - ["\\", "\"", ..rest] -> take_string(rest, ["\"", ..acc]) 158 - ["\\", "\\", ..rest] -> take_string(rest, ["\\", ..acc]) 159 - 160 - [char, ..rest] -> take_string(rest, [char, ..acc]) 161 - } 162 - } 163 - 164 - /// Take a number (int or float) 165 - fn take_number( 166 - graphemes: List(String), 167 - ) -> Result(#(String, Bool, List(String)), LexerError) { 168 - let #(num_chars, rest) = take_while(graphemes, is_number_char) 169 - let num_str = string.concat(num_chars) 170 - 171 - let is_float = 172 - string.contains(num_str, ".") 173 - || string.contains(num_str, "e") 174 - || string.contains(num_str, "E") 175 - 176 - Ok(#(num_str, is_float, rest)) 177 - } 178 - 179 - /// Take a name (identifier) 180 - fn take_name(graphemes: List(String)) -> #(String, List(String)) { 181 - let #(name_chars, rest) = take_while(graphemes, is_name_char) 182 - #(string.concat(name_chars), rest) 183 - } 184 - 185 - /// Take characters while predicate is true 186 - fn take_while( 187 - graphemes: List(String), 188 - predicate: fn(String) -> Bool, 189 - ) -> #(List(String), List(String)) { 190 - do_take_while(graphemes, predicate, []) 191 - } 192 - 193 - fn do_take_while( 194 - graphemes: List(String), 195 - predicate: fn(String) -> Bool, 196 - acc: List(String), 197 - ) -> #(List(String), List(String)) { 198 - case graphemes { 199 - [char, ..rest] -> { 200 - case predicate(char) { 201 - True -> do_take_while(rest, predicate, [char, ..acc]) 202 - False -> #(list.reverse(acc), graphemes) 203 - } 204 - } 205 - _ -> #(list.reverse(acc), graphemes) 206 - } 207 - } 208 - 209 - /// Check if character can start a name 210 - fn is_name_start(char: String) -> Bool { 211 - case char { 212 - "a" 213 - | "b" 214 - | "c" 215 - | "d" 216 - | "e" 217 - | "f" 218 - | "g" 219 - | "h" 220 - | "i" 221 - | "j" 222 - | "k" 223 - | "l" 224 - | "m" 225 - | "n" 226 - | "o" 227 - | "p" 228 - | "q" 229 - | "r" 230 - | "s" 231 - | "t" 232 - | "u" 233 - | "v" 234 - | "w" 235 - | "x" 236 - | "y" 237 - | "z" -> True 238 - "A" 239 - | "B" 240 - | "C" 241 - | "D" 242 - | "E" 243 - | "F" 244 - | "G" 245 - | "H" 246 - | "I" 247 - | "J" 248 - | "K" 249 - | "L" 250 - | "M" 251 - | "N" 252 - | "O" 253 - | "P" 254 - | "Q" 255 - | "R" 256 - | "S" 257 - | "T" 258 - | "U" 259 - | "V" 260 - | "W" 261 - | "X" 262 - | "Y" 263 - | "Z" -> True 264 - "_" -> True 265 - _ -> False 266 - } 267 - } 268 - 269 - /// Check if character can be part of a name 270 - fn is_name_char(char: String) -> Bool { 271 - is_name_start(char) || is_digit(char) 272 - } 273 - 274 - /// Check if character is a digit 275 - fn is_digit(char: String) -> Bool { 276 - case char { 277 - "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 278 - _ -> False 279 - } 280 - } 281 - 282 - /// Check if character can be part of a number 283 - fn is_number_char(char: String) -> Bool { 284 - is_digit(char) 285 - || char == "." 286 - || char == "e" 287 - || char == "E" 288 - || char == "-" 289 - || char == "+" 290 - } 291 - 292 - /// Filter out ignored tokens (whitespace and comments) 293 - fn filter_ignored(tokens: List(Token)) -> List(Token) { 294 - list.filter(tokens, fn(token) { 295 - case token { 296 - Whitespace -> False 297 - Comment(_) -> False 298 - _ -> True 299 - } 300 - }) 301 - }
···
-568
graphql/src/graphql/parser.gleam
··· 1 - /// GraphQL Parser - Build AST from tokens 2 - /// 3 - /// Per GraphQL spec Section 2 - Language 4 - /// Converts a token stream into an Abstract Syntax Tree 5 - import gleam/list 6 - import gleam/option.{type Option, None, Some} 7 - import gleam/result 8 - import graphql/lexer 9 - 10 - /// GraphQL Document (top-level) 11 - pub type Document { 12 - Document(operations: List(Operation)) 13 - } 14 - 15 - /// GraphQL Operation 16 - pub type Operation { 17 - Query(SelectionSet) 18 - NamedQuery(name: String, variables: List(Variable), selections: SelectionSet) 19 - Mutation(SelectionSet) 20 - NamedMutation( 21 - name: String, 22 - variables: List(Variable), 23 - selections: SelectionSet, 24 - ) 25 - Subscription(SelectionSet) 26 - NamedSubscription( 27 - name: String, 28 - variables: List(Variable), 29 - selections: SelectionSet, 30 - ) 31 - FragmentDefinition( 32 - name: String, 33 - type_condition: String, 34 - selections: SelectionSet, 35 - ) 36 - } 37 - 38 - /// Selection Set (list of fields) 39 - pub type SelectionSet { 40 - SelectionSet(selections: List(Selection)) 41 - } 42 - 43 - /// Selection (field or fragment) 44 - pub type Selection { 45 - Field( 46 - name: String, 47 - alias: Option(String), 48 - arguments: List(Argument), 49 - selections: List(Selection), 50 - ) 51 - FragmentSpread(name: String) 52 - InlineFragment(type_condition: Option(String), selections: List(Selection)) 53 - } 54 - 55 - /// Argument (name: value) 56 - pub type Argument { 57 - Argument(name: String, value: ArgumentValue) 58 - } 59 - 60 - /// Argument value types 61 - pub type ArgumentValue { 62 - IntValue(String) 63 - FloatValue(String) 64 - StringValue(String) 65 - BooleanValue(Bool) 66 - NullValue 67 - EnumValue(String) 68 - ListValue(List(ArgumentValue)) 69 - ObjectValue(List(#(String, ArgumentValue))) 70 - VariableValue(String) 71 - } 72 - 73 - /// Variable definition 74 - pub type Variable { 75 - Variable(name: String, type_: String) 76 - } 77 - 78 - pub type ParseError { 79 - UnexpectedToken(lexer.Token, String) 80 - UnexpectedEndOfInput(String) 81 - LexerError(lexer.LexerError) 82 - } 83 - 84 - /// Parse a GraphQL query string into a Document 85 - pub fn parse(source: String) -> Result(Document, ParseError) { 86 - source 87 - |> lexer.tokenize 88 - |> result.map_error(LexerError) 89 - |> result.try(parse_document) 90 - } 91 - 92 - /// Parse tokens into a Document 93 - fn parse_document(tokens: List(lexer.Token)) -> Result(Document, ParseError) { 94 - case tokens { 95 - [] -> Error(UnexpectedEndOfInput("Expected query or operation")) 96 - _ -> { 97 - case parse_operations(tokens, []) { 98 - Ok(#(operations, _remaining)) -> Ok(Document(operations)) 99 - Error(err) -> Error(err) 100 - } 101 - } 102 - } 103 - } 104 - 105 - /// Parse operations (queries/mutations) 106 - fn parse_operations( 107 - tokens: List(lexer.Token), 108 - acc: List(Operation), 109 - ) -> Result(#(List(Operation), List(lexer.Token)), ParseError) { 110 - case tokens { 111 - [] -> Ok(#(list.reverse(acc), [])) 112 - 113 - // Named query: "query Name(...) { ... }" or "query Name { ... }" 114 - [lexer.Name("query"), lexer.Name(name), ..rest] -> { 115 - // Check if there are variable definitions 116 - case rest { 117 - [lexer.ParenOpen, ..vars_rest] -> { 118 - // Parse variable definitions 119 - case parse_variable_definitions(vars_rest) { 120 - Ok(#(variables, after_vars)) -> { 121 - case parse_selection_set(after_vars) { 122 - Ok(#(selections, remaining)) -> { 123 - let op = NamedQuery(name, variables, selections) 124 - parse_operations(remaining, [op, ..acc]) 125 - } 126 - Error(err) -> Error(err) 127 - } 128 - } 129 - Error(err) -> Error(err) 130 - } 131 - } 132 - _ -> { 133 - // No variables, parse selection set directly 134 - case parse_selection_set(rest) { 135 - Ok(#(selections, remaining)) -> { 136 - let op = NamedQuery(name, [], selections) 137 - parse_operations(remaining, [op, ..acc]) 138 - } 139 - Error(err) -> Error(err) 140 - } 141 - } 142 - } 143 - } 144 - 145 - // Named mutation: "mutation Name(...) { ... }" or "mutation Name { ... }" 146 - [lexer.Name("mutation"), lexer.Name(name), ..rest] -> { 147 - // Check if there are variable definitions 148 - case rest { 149 - [lexer.ParenOpen, ..vars_rest] -> { 150 - // Parse variable definitions 151 - case parse_variable_definitions(vars_rest) { 152 - Ok(#(variables, after_vars)) -> { 153 - case parse_selection_set(after_vars) { 154 - Ok(#(selections, remaining)) -> { 155 - let op = NamedMutation(name, variables, selections) 156 - parse_operations(remaining, [op, ..acc]) 157 - } 158 - Error(err) -> Error(err) 159 - } 160 - } 161 - Error(err) -> Error(err) 162 - } 163 - } 164 - _ -> { 165 - // No variables, parse selection set directly 166 - case parse_selection_set(rest) { 167 - Ok(#(selections, remaining)) -> { 168 - let op = NamedMutation(name, [], selections) 169 - parse_operations(remaining, [op, ..acc]) 170 - } 171 - Error(err) -> Error(err) 172 - } 173 - } 174 - } 175 - } 176 - 177 - // Named subscription: "subscription Name(...) { ... }" or "subscription Name { ... }" 178 - [lexer.Name("subscription"), lexer.Name(name), ..rest] -> { 179 - // Check if there are variable definitions 180 - case rest { 181 - [lexer.ParenOpen, ..vars_rest] -> { 182 - // Parse variable definitions 183 - case parse_variable_definitions(vars_rest) { 184 - Ok(#(variables, after_vars)) -> { 185 - case parse_selection_set(after_vars) { 186 - Ok(#(selections, remaining)) -> { 187 - let op = NamedSubscription(name, variables, selections) 188 - parse_operations(remaining, [op, ..acc]) 189 - } 190 - Error(err) -> Error(err) 191 - } 192 - } 193 - Error(err) -> Error(err) 194 - } 195 - } 196 - _ -> { 197 - // No variables, parse selection set directly 198 - case parse_selection_set(rest) { 199 - Ok(#(selections, remaining)) -> { 200 - let op = NamedSubscription(name, [], selections) 201 - parse_operations(remaining, [op, ..acc]) 202 - } 203 - Error(err) -> Error(err) 204 - } 205 - } 206 - } 207 - } 208 - 209 - // Anonymous query: "query { ... }" 210 - [lexer.Name("query"), lexer.BraceOpen, ..] -> { 211 - case parse_selection_set(list.drop(tokens, 1)) { 212 - Ok(#(selections, remaining)) -> { 213 - let op = Query(selections) 214 - parse_operations(remaining, [op, ..acc]) 215 - } 216 - Error(err) -> Error(err) 217 - } 218 - } 219 - 220 - // Anonymous mutation: "mutation { ... }" 221 - [lexer.Name("mutation"), lexer.BraceOpen, ..] -> { 222 - case parse_selection_set(list.drop(tokens, 1)) { 223 - Ok(#(selections, remaining)) -> { 224 - let op = Mutation(selections) 225 - parse_operations(remaining, [op, ..acc]) 226 - } 227 - Error(err) -> Error(err) 228 - } 229 - } 230 - 231 - // Anonymous subscription: "subscription { ... }" 232 - [lexer.Name("subscription"), lexer.BraceOpen, ..] -> { 233 - case parse_selection_set(list.drop(tokens, 1)) { 234 - Ok(#(selections, remaining)) -> { 235 - let op = Subscription(selections) 236 - parse_operations(remaining, [op, ..acc]) 237 - } 238 - Error(err) -> Error(err) 239 - } 240 - } 241 - 242 - // Fragment definition: "fragment Name on Type { ... }" 243 - [ 244 - lexer.Name("fragment"), 245 - lexer.Name(name), 246 - lexer.Name("on"), 247 - lexer.Name(type_condition), 248 - ..rest 249 - ] -> { 250 - case parse_selection_set(rest) { 251 - Ok(#(selections, remaining)) -> { 252 - let op = FragmentDefinition(name, type_condition, selections) 253 - parse_operations(remaining, [op, ..acc]) 254 - } 255 - Error(err) -> Error(err) 256 - } 257 - } 258 - 259 - // Anonymous query: "{ ... }" 260 - [lexer.BraceOpen, ..] -> { 261 - case parse_selection_set(tokens) { 262 - Ok(#(selections, remaining)) -> { 263 - let op = Query(selections) 264 - // Continue parsing to see if there are more operations (e.g., fragment definitions) 265 - parse_operations(remaining, [op, ..acc]) 266 - } 267 - Error(err) -> Error(err) 268 - } 269 - } 270 - 271 - // Any other token when we have operations means we're done 272 - _ -> { 273 - case acc { 274 - [] -> 275 - Error(UnexpectedToken( 276 - list.first(tokens) |> result.unwrap(lexer.BraceClose), 277 - "Expected query, mutation, subscription, fragment, or '{'", 278 - )) 279 - _ -> Ok(#(list.reverse(acc), tokens)) 280 - } 281 - } 282 - } 283 - } 284 - 285 - /// Parse selection set: { field1 field2 ... } 286 - fn parse_selection_set( 287 - tokens: List(lexer.Token), 288 - ) -> Result(#(SelectionSet, List(lexer.Token)), ParseError) { 289 - case tokens { 290 - [lexer.BraceOpen, ..rest] -> { 291 - case parse_selections(rest, []) { 292 - Ok(#(selections, [lexer.BraceClose, ..remaining])) -> 293 - Ok(#(SelectionSet(selections), remaining)) 294 - Ok(#(_, _remaining)) -> 295 - Error(UnexpectedEndOfInput("Expected '}' to close selection set")) 296 - Error(err) -> Error(err) 297 - } 298 - } 299 - [token, ..] -> Error(UnexpectedToken(token, "Expected '{'")) 300 - [] -> Error(UnexpectedEndOfInput("Expected '{'")) 301 - } 302 - } 303 - 304 - /// Parse selections (fields) 305 - fn parse_selections( 306 - tokens: List(lexer.Token), 307 - acc: List(Selection), 308 - ) -> Result(#(List(Selection), List(lexer.Token)), ParseError) { 309 - case tokens { 310 - // End of selection set 311 - [lexer.BraceClose, ..] -> Ok(#(list.reverse(acc), tokens)) 312 - 313 - // Inline fragment: "... on Type { ... }" - Check this BEFORE fragment spread 314 - [lexer.Spread, lexer.Name("on"), lexer.Name(type_condition), ..rest] -> { 315 - case parse_selection_set(rest) { 316 - Ok(#(SelectionSet(selections), remaining)) -> { 317 - let inline = InlineFragment(Some(type_condition), selections) 318 - parse_selections(remaining, [inline, ..acc]) 319 - } 320 - Error(err) -> Error(err) 321 - } 322 - } 323 - 324 - // Fragment spread: "...FragmentName" 325 - [lexer.Spread, lexer.Name(name), ..rest] -> { 326 - let spread = FragmentSpread(name) 327 - parse_selections(rest, [spread, ..acc]) 328 - } 329 - 330 - // Field with alias: "alias: fieldName" 331 - [lexer.Name(alias), lexer.Colon, lexer.Name(field_name), ..rest] -> { 332 - case parse_field_with_alias(field_name, Some(alias), rest) { 333 - Ok(#(field, remaining)) -> { 334 - parse_selections(remaining, [field, ..acc]) 335 - } 336 - Error(err) -> Error(err) 337 - } 338 - } 339 - 340 - // Field without alias 341 - [lexer.Name(name), ..rest] -> { 342 - case parse_field_with_alias(name, None, rest) { 343 - Ok(#(field, remaining)) -> { 344 - parse_selections(remaining, [field, ..acc]) 345 - } 346 - Error(err) -> Error(err) 347 - } 348 - } 349 - 350 - [] -> Error(UnexpectedEndOfInput("Expected field or '}'")) 351 - [token, ..] -> 352 - Error(UnexpectedToken(token, "Expected field name or fragment")) 353 - } 354 - } 355 - 356 - /// Parse a field with optional alias, arguments and nested selections 357 - fn parse_field_with_alias( 358 - name: String, 359 - alias: Option(String), 360 - tokens: List(lexer.Token), 361 - ) -> Result(#(Selection, List(lexer.Token)), ParseError) { 362 - // Parse arguments if present 363 - let #(arguments, after_args) = case tokens { 364 - [lexer.ParenOpen, ..] -> { 365 - case parse_arguments(tokens) { 366 - Ok(result) -> result 367 - Error(_err) -> #([], tokens) 368 - // No arguments 369 - } 370 - } 371 - _ -> #([], tokens) 372 - } 373 - 374 - // Parse nested selection set if present 375 - case after_args { 376 - [lexer.BraceOpen, ..] -> { 377 - case parse_nested_selections(after_args) { 378 - Ok(#(nested, remaining)) -> 379 - Ok(#(Field(name, alias, arguments, nested), remaining)) 380 - Error(err) -> Error(err) 381 - } 382 - } 383 - _ -> Ok(#(Field(name, alias, arguments, []), after_args)) 384 - } 385 - } 386 - 387 - /// Parse nested selections for a field 388 - fn parse_nested_selections( 389 - tokens: List(lexer.Token), 390 - ) -> Result(#(List(Selection), List(lexer.Token)), ParseError) { 391 - case tokens { 392 - [lexer.BraceOpen, ..rest] -> { 393 - case parse_selections(rest, []) { 394 - Ok(#(selections, [lexer.BraceClose, ..remaining])) -> 395 - Ok(#(selections, remaining)) 396 - Ok(#(_, _remaining)) -> 397 - Error(UnexpectedEndOfInput( 398 - "Expected '}' to close nested selection set", 399 - )) 400 - Error(err) -> Error(err) 401 - } 402 - } 403 - _ -> Ok(#([], tokens)) 404 - } 405 - } 406 - 407 - /// Parse arguments: (arg1: value1, arg2: value2) 408 - fn parse_arguments( 409 - tokens: List(lexer.Token), 410 - ) -> Result(#(List(Argument), List(lexer.Token)), ParseError) { 411 - case tokens { 412 - [lexer.ParenOpen, ..rest] -> { 413 - case parse_argument_list(rest, []) { 414 - Ok(#(args, [lexer.ParenClose, ..remaining])) -> Ok(#(args, remaining)) 415 - Ok(#(_, _remaining)) -> 416 - Error(UnexpectedEndOfInput("Expected ')' to close arguments")) 417 - Error(err) -> Error(err) 418 - } 419 - } 420 - _ -> Ok(#([], tokens)) 421 - } 422 - } 423 - 424 - /// Parse list of arguments 425 - fn parse_argument_list( 426 - tokens: List(lexer.Token), 427 - acc: List(Argument), 428 - ) -> Result(#(List(Argument), List(lexer.Token)), ParseError) { 429 - case tokens { 430 - // End of arguments 431 - [lexer.ParenClose, ..] -> Ok(#(list.reverse(acc), tokens)) 432 - 433 - // Argument: name: value 434 - [lexer.Name(name), lexer.Colon, ..rest] -> { 435 - case parse_argument_value(rest) { 436 - Ok(#(value, remaining)) -> { 437 - let arg = Argument(name, value) 438 - // Skip optional comma 439 - let after_comma = case remaining { 440 - [lexer.Comma, ..r] -> r 441 - _ -> remaining 442 - } 443 - parse_argument_list(after_comma, [arg, ..acc]) 444 - } 445 - Error(err) -> Error(err) 446 - } 447 - } 448 - 449 - [] -> Error(UnexpectedEndOfInput("Expected argument or ')'")) 450 - [token, ..] -> Error(UnexpectedToken(token, "Expected argument name")) 451 - } 452 - } 453 - 454 - /// Parse argument value 455 - fn parse_argument_value( 456 - tokens: List(lexer.Token), 457 - ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 458 - case tokens { 459 - [lexer.Int(val), ..rest] -> Ok(#(IntValue(val), rest)) 460 - [lexer.Float(val), ..rest] -> Ok(#(FloatValue(val), rest)) 461 - [lexer.String(val), ..rest] -> Ok(#(StringValue(val), rest)) 462 - [lexer.Name("true"), ..rest] -> Ok(#(BooleanValue(True), rest)) 463 - [lexer.Name("false"), ..rest] -> Ok(#(BooleanValue(False), rest)) 464 - [lexer.Name("null"), ..rest] -> Ok(#(NullValue, rest)) 465 - [lexer.Name(name), ..rest] -> Ok(#(EnumValue(name), rest)) 466 - [lexer.Dollar, lexer.Name(name), ..rest] -> Ok(#(VariableValue(name), rest)) 467 - [lexer.BracketOpen, ..rest] -> parse_list_value(rest) 468 - [lexer.BraceOpen, ..rest] -> parse_object_value(rest) 469 - [] -> Error(UnexpectedEndOfInput("Expected value")) 470 - [token, ..] -> Error(UnexpectedToken(token, "Expected value")) 471 - } 472 - } 473 - 474 - /// Parse list value: [value, value, ...] 475 - fn parse_list_value( 476 - tokens: List(lexer.Token), 477 - ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 478 - case tokens { 479 - [lexer.BracketClose, ..rest] -> Ok(#(ListValue([]), rest)) 480 - _ -> parse_list_value_items(tokens, []) 481 - } 482 - } 483 - 484 - /// Parse list value items recursively 485 - fn parse_list_value_items( 486 - tokens: List(lexer.Token), 487 - acc: List(ArgumentValue), 488 - ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 489 - case tokens { 490 - [lexer.BracketClose, ..rest] -> Ok(#(ListValue(list.reverse(acc)), rest)) 491 - [lexer.Comma, ..rest] -> parse_list_value_items(rest, acc) 492 - _ -> { 493 - use #(value, rest) <- result.try(parse_argument_value(tokens)) 494 - parse_list_value_items(rest, [value, ..acc]) 495 - } 496 - } 497 - } 498 - 499 - /// Parse object value: {field: value, field: value, ...} 500 - fn parse_object_value( 501 - tokens: List(lexer.Token), 502 - ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 503 - case tokens { 504 - [lexer.BraceClose, ..rest] -> Ok(#(ObjectValue([]), rest)) 505 - _ -> parse_object_value_fields(tokens, []) 506 - } 507 - } 508 - 509 - /// Parse object value fields recursively 510 - fn parse_object_value_fields( 511 - tokens: List(lexer.Token), 512 - acc: List(#(String, ArgumentValue)), 513 - ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 514 - case tokens { 515 - [lexer.BraceClose, ..rest] -> Ok(#(ObjectValue(list.reverse(acc)), rest)) 516 - [lexer.Comma, ..rest] -> parse_object_value_fields(rest, acc) 517 - [lexer.Name(field_name), lexer.Colon, ..rest] -> { 518 - use #(value, rest2) <- result.try(parse_argument_value(rest)) 519 - parse_object_value_fields(rest2, [#(field_name, value), ..acc]) 520 - } 521 - [] -> Error(UnexpectedEndOfInput("Expected field name or }")) 522 - [token, ..] -> Error(UnexpectedToken(token, "Expected field name or }")) 523 - } 524 - } 525 - 526 - /// Parse variable definitions: ($var1: Type!, $var2: Type) 527 - /// Returns the list of variables and remaining tokens after the closing paren 528 - fn parse_variable_definitions( 529 - tokens: List(lexer.Token), 530 - ) -> Result(#(List(Variable), List(lexer.Token)), ParseError) { 531 - parse_variable_definitions_loop(tokens, []) 532 - } 533 - 534 - /// Parse variable definitions loop 535 - fn parse_variable_definitions_loop( 536 - tokens: List(lexer.Token), 537 - acc: List(Variable), 538 - ) -> Result(#(List(Variable), List(lexer.Token)), ParseError) { 539 - case tokens { 540 - // End of variable definitions 541 - [lexer.ParenClose, ..rest] -> Ok(#(list.reverse(acc), rest)) 542 - 543 - // Skip commas 544 - [lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc) 545 - 546 - // Parse a variable: $name: Type! or $name: Type 547 - [lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> { 548 - // Parse the type (Name or Name!) 549 - case rest { 550 - [lexer.Name(type_name), lexer.Exclamation, ..rest2] -> { 551 - // Non-null type 552 - let variable = Variable(var_name, type_name <> "!") 553 - parse_variable_definitions_loop(rest2, [variable, ..acc]) 554 - } 555 - [lexer.Name(type_name), ..rest2] -> { 556 - // Nullable type 557 - let variable = Variable(var_name, type_name) 558 - parse_variable_definitions_loop(rest2, [variable, ..acc]) 559 - } 560 - [] -> Error(UnexpectedEndOfInput("Expected type after :")) 561 - [token, ..] -> Error(UnexpectedToken(token, "Expected type name")) 562 - } 563 - } 564 - 565 - [] -> Error(UnexpectedEndOfInput("Expected variable definition or )")) 566 - [token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )")) 567 - } 568 - }
···
-508
graphql/src/graphql/schema.gleam
··· 1 - /// GraphQL Schema - Type System 2 - /// 3 - /// Per GraphQL spec Section 3 - Type System 4 - /// Defines the type system including scalars, objects, enums, etc. 5 - import gleam/dict.{type Dict} 6 - import gleam/list 7 - import gleam/option.{type Option, None} 8 - import graphql/value 9 - 10 - /// Resolver context - will contain request context, data loaders, etc. 11 - pub type Context { 12 - Context( 13 - data: Option(value.Value), 14 - arguments: Dict(String, value.Value), 15 - variables: Dict(String, value.Value), 16 - ) 17 - } 18 - 19 - /// Helper to create a context without arguments or variables 20 - pub fn context(data: Option(value.Value)) -> Context { 21 - Context(data, dict.new(), dict.new()) 22 - } 23 - 24 - /// Helper to create a context with variables 25 - pub fn context_with_variables( 26 - data: Option(value.Value), 27 - variables: Dict(String, value.Value), 28 - ) -> Context { 29 - Context(data, dict.new(), variables) 30 - } 31 - 32 - /// Helper to get an argument value from context 33 - pub fn get_argument(ctx: Context, name: String) -> Option(value.Value) { 34 - dict.get(ctx.arguments, name) |> option.from_result 35 - } 36 - 37 - /// Helper to get a variable value from context 38 - pub fn get_variable(ctx: Context, name: String) -> Option(value.Value) { 39 - dict.get(ctx.variables, name) |> option.from_result 40 - } 41 - 42 - /// Field resolver function type 43 - pub type Resolver = 44 - fn(Context) -> Result(value.Value, String) 45 - 46 - /// GraphQL Type 47 - pub opaque type Type { 48 - ScalarType(name: String) 49 - ObjectType(name: String, description: String, fields: List(Field)) 50 - InputObjectType(name: String, description: String, fields: List(InputField)) 51 - EnumType(name: String, description: String, values: List(EnumValue)) 52 - UnionType( 53 - name: String, 54 - description: String, 55 - possible_types: List(Type), 56 - type_resolver: fn(Context) -> Result(String, String), 57 - ) 58 - ListType(inner_type: Type) 59 - NonNullType(inner_type: Type) 60 - } 61 - 62 - /// GraphQL Field 63 - pub opaque type Field { 64 - Field( 65 - name: String, 66 - field_type: Type, 67 - description: String, 68 - arguments: List(Argument), 69 - resolver: Resolver, 70 - ) 71 - } 72 - 73 - /// GraphQL Argument 74 - pub opaque type Argument { 75 - Argument( 76 - name: String, 77 - arg_type: Type, 78 - description: String, 79 - default_value: Option(value.Value), 80 - ) 81 - } 82 - 83 - /// GraphQL Input Field (for InputObject types) 84 - pub opaque type InputField { 85 - InputField( 86 - name: String, 87 - field_type: Type, 88 - description: String, 89 - default_value: Option(value.Value), 90 - ) 91 - } 92 - 93 - /// GraphQL Enum Value 94 - pub opaque type EnumValue { 95 - EnumValue(name: String, description: String) 96 - } 97 - 98 - /// GraphQL Schema 99 - pub opaque type Schema { 100 - Schema( 101 - query_type: Type, 102 - mutation_type: Option(Type), 103 - subscription_type: Option(Type), 104 - ) 105 - } 106 - 107 - // Built-in scalar types 108 - pub fn string_type() -> Type { 109 - ScalarType("String") 110 - } 111 - 112 - pub fn int_type() -> Type { 113 - ScalarType("Int") 114 - } 115 - 116 - pub fn float_type() -> Type { 117 - ScalarType("Float") 118 - } 119 - 120 - pub fn boolean_type() -> Type { 121 - ScalarType("Boolean") 122 - } 123 - 124 - pub fn id_type() -> Type { 125 - ScalarType("ID") 126 - } 127 - 128 - // Type constructors 129 - pub fn object_type( 130 - name: String, 131 - description: String, 132 - fields: List(Field), 133 - ) -> Type { 134 - ObjectType(name, description, fields) 135 - } 136 - 137 - pub fn enum_type( 138 - name: String, 139 - description: String, 140 - values: List(EnumValue), 141 - ) -> Type { 142 - EnumType(name, description, values) 143 - } 144 - 145 - pub fn input_object_type( 146 - name: String, 147 - description: String, 148 - fields: List(InputField), 149 - ) -> Type { 150 - InputObjectType(name, description, fields) 151 - } 152 - 153 - pub fn union_type( 154 - name: String, 155 - description: String, 156 - possible_types: List(Type), 157 - type_resolver: fn(Context) -> Result(String, String), 158 - ) -> Type { 159 - UnionType(name, description, possible_types, type_resolver) 160 - } 161 - 162 - pub fn list_type(inner_type: Type) -> Type { 163 - ListType(inner_type) 164 - } 165 - 166 - pub fn non_null(inner_type: Type) -> Type { 167 - NonNullType(inner_type) 168 - } 169 - 170 - // Field constructors 171 - pub fn field( 172 - name: String, 173 - field_type: Type, 174 - description: String, 175 - resolver: Resolver, 176 - ) -> Field { 177 - Field(name, field_type, description, [], resolver) 178 - } 179 - 180 - pub fn field_with_args( 181 - name: String, 182 - field_type: Type, 183 - description: String, 184 - arguments: List(Argument), 185 - resolver: Resolver, 186 - ) -> Field { 187 - Field(name, field_type, description, arguments, resolver) 188 - } 189 - 190 - // Argument constructor 191 - pub fn argument( 192 - name: String, 193 - arg_type: Type, 194 - description: String, 195 - default_value: Option(value.Value), 196 - ) -> Argument { 197 - Argument(name, arg_type, description, default_value) 198 - } 199 - 200 - // Input field constructor 201 - pub fn input_field( 202 - name: String, 203 - field_type: Type, 204 - description: String, 205 - default_value: Option(value.Value), 206 - ) -> InputField { 207 - InputField(name, field_type, description, default_value) 208 - } 209 - 210 - // Enum value constructor 211 - pub fn enum_value(name: String, description: String) -> EnumValue { 212 - EnumValue(name, description) 213 - } 214 - 215 - // Schema constructor 216 - pub fn schema(query_type: Type, mutation_type: Option(Type)) -> Schema { 217 - Schema(query_type, mutation_type, None) 218 - } 219 - 220 - // Schema constructor with subscriptions 221 - pub fn schema_with_subscriptions( 222 - query_type: Type, 223 - mutation_type: Option(Type), 224 - subscription_type: Option(Type), 225 - ) -> Schema { 226 - Schema(query_type, mutation_type, subscription_type) 227 - } 228 - 229 - // Accessors 230 - pub fn type_name(t: Type) -> String { 231 - case t { 232 - ScalarType(name) -> name 233 - ObjectType(name, _, _) -> name 234 - InputObjectType(name, _, _) -> name 235 - EnumType(name, _, _) -> name 236 - UnionType(name, _, _, _) -> name 237 - ListType(inner) -> "[" <> type_name(inner) <> "]" 238 - NonNullType(inner) -> type_name(inner) <> "!" 239 - } 240 - } 241 - 242 - pub fn field_name(f: Field) -> String { 243 - case f { 244 - Field(name, _, _, _, _) -> name 245 - } 246 - } 247 - 248 - pub fn query_type(s: Schema) -> Type { 249 - case s { 250 - Schema(query_type, _, _) -> query_type 251 - } 252 - } 253 - 254 - pub fn get_mutation_type(s: Schema) -> Option(Type) { 255 - case s { 256 - Schema(_, mutation_type, _) -> mutation_type 257 - } 258 - } 259 - 260 - pub fn get_subscription_type(s: Schema) -> Option(Type) { 261 - case s { 262 - Schema(_, _, subscription_type) -> subscription_type 263 - } 264 - } 265 - 266 - pub fn is_non_null(t: Type) -> Bool { 267 - case t { 268 - NonNullType(_) -> True 269 - _ -> False 270 - } 271 - } 272 - 273 - pub fn is_list(t: Type) -> Bool { 274 - case t { 275 - ListType(_) -> True 276 - _ -> False 277 - } 278 - } 279 - 280 - pub fn is_input_object(t: Type) -> Bool { 281 - case t { 282 - InputObjectType(_, _, _) -> True 283 - _ -> False 284 - } 285 - } 286 - 287 - pub fn type_description(t: Type) -> String { 288 - case t { 289 - ObjectType(_, description, _) -> description 290 - InputObjectType(_, description, _) -> description 291 - EnumType(_, description, _) -> description 292 - _ -> "" 293 - } 294 - } 295 - 296 - // Field resolution helpers 297 - pub fn resolve_field(field: Field, ctx: Context) -> Result(value.Value, String) { 298 - case field { 299 - Field(_, _, _, _, resolver) -> resolver(ctx) 300 - } 301 - } 302 - 303 - pub fn get_field(t: Type, field_name: String) -> Option(Field) { 304 - case t { 305 - ObjectType(_, _, fields) -> { 306 - list.find(fields, fn(f) { 307 - case f { 308 - Field(name, _, _, _, _) -> name == field_name 309 - } 310 - }) 311 - |> option.from_result 312 - } 313 - NonNullType(inner) -> get_field(inner, field_name) 314 - _ -> None 315 - } 316 - } 317 - 318 - /// Get the type of a field 319 - pub fn field_type(field: Field) -> Type { 320 - case field { 321 - Field(_, ft, _, _, _) -> ft 322 - } 323 - } 324 - 325 - /// Get all fields from an ObjectType 326 - pub fn get_fields(t: Type) -> List(Field) { 327 - case t { 328 - ObjectType(_, _, fields) -> fields 329 - _ -> [] 330 - } 331 - } 332 - 333 - /// Get all input fields from an InputObjectType 334 - pub fn get_input_fields(t: Type) -> List(InputField) { 335 - case t { 336 - InputObjectType(_, _, fields) -> fields 337 - _ -> [] 338 - } 339 - } 340 - 341 - /// Get field description 342 - pub fn field_description(field: Field) -> String { 343 - case field { 344 - Field(_, _, desc, _, _) -> desc 345 - } 346 - } 347 - 348 - /// Get field arguments 349 - pub fn field_arguments(field: Field) -> List(Argument) { 350 - case field { 351 - Field(_, _, _, args, _) -> args 352 - } 353 - } 354 - 355 - /// Get argument name 356 - pub fn argument_name(arg: Argument) -> String { 357 - case arg { 358 - Argument(name, _, _, _) -> name 359 - } 360 - } 361 - 362 - /// Get argument type 363 - pub fn argument_type(arg: Argument) -> Type { 364 - case arg { 365 - Argument(_, arg_type, _, _) -> arg_type 366 - } 367 - } 368 - 369 - /// Get argument description 370 - pub fn argument_description(arg: Argument) -> String { 371 - case arg { 372 - Argument(_, _, desc, _) -> desc 373 - } 374 - } 375 - 376 - /// Get input field type 377 - pub fn input_field_type(input_field: InputField) -> Type { 378 - case input_field { 379 - InputField(_, field_type, _, _) -> field_type 380 - } 381 - } 382 - 383 - /// Get input field name 384 - pub fn input_field_name(input_field: InputField) -> String { 385 - case input_field { 386 - InputField(name, _, _, _) -> name 387 - } 388 - } 389 - 390 - /// Get input field description 391 - pub fn input_field_description(input_field: InputField) -> String { 392 - case input_field { 393 - InputField(_, _, desc, _) -> desc 394 - } 395 - } 396 - 397 - /// Get all enum values from an EnumType 398 - pub fn get_enum_values(t: Type) -> List(EnumValue) { 399 - case t { 400 - EnumType(_, _, values) -> values 401 - _ -> [] 402 - } 403 - } 404 - 405 - /// Get enum value name 406 - pub fn enum_value_name(enum_value: EnumValue) -> String { 407 - case enum_value { 408 - EnumValue(name, _) -> name 409 - } 410 - } 411 - 412 - /// Get enum value description 413 - pub fn enum_value_description(enum_value: EnumValue) -> String { 414 - case enum_value { 415 - EnumValue(_, desc) -> desc 416 - } 417 - } 418 - 419 - /// Check if type is a scalar 420 - pub fn is_scalar(t: Type) -> Bool { 421 - case t { 422 - ScalarType(_) -> True 423 - _ -> False 424 - } 425 - } 426 - 427 - /// Check if type is an object 428 - pub fn is_object(t: Type) -> Bool { 429 - case t { 430 - ObjectType(_, _, _) -> True 431 - _ -> False 432 - } 433 - } 434 - 435 - /// Check if type is an enum 436 - pub fn is_enum(t: Type) -> Bool { 437 - case t { 438 - EnumType(_, _, _) -> True 439 - _ -> False 440 - } 441 - } 442 - 443 - /// Check if type is a union 444 - pub fn is_union(t: Type) -> Bool { 445 - case t { 446 - UnionType(_, _, _, _) -> True 447 - _ -> False 448 - } 449 - } 450 - 451 - /// Get the possible types from a union 452 - pub fn get_possible_types(t: Type) -> List(Type) { 453 - case t { 454 - UnionType(_, _, possible_types, _) -> possible_types 455 - _ -> [] 456 - } 457 - } 458 - 459 - /// Resolve a union type to its concrete type using the type resolver 460 - pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) { 461 - case t { 462 - UnionType(_, _, possible_types, type_resolver) -> { 463 - // Call the type resolver to get the concrete type name 464 - case type_resolver(ctx) { 465 - Ok(resolved_type_name) -> { 466 - // Find the concrete type in possible_types 467 - case 468 - list.find(possible_types, fn(pt) { 469 - type_name(pt) == resolved_type_name 470 - }) 471 - { 472 - Ok(concrete_type) -> Ok(concrete_type) 473 - Error(_) -> 474 - Error( 475 - "Type resolver returned '" 476 - <> resolved_type_name 477 - <> "' which is not a possible type of this union", 478 - ) 479 - } 480 - } 481 - Error(err) -> Error(err) 482 - } 483 - } 484 - _ -> Error("Cannot resolve non-union type") 485 - } 486 - } 487 - 488 - /// Get the inner type from a wrapping type (List or NonNull) 489 - pub fn inner_type(t: Type) -> option.Option(Type) { 490 - case t { 491 - ListType(inner) -> option.Some(inner) 492 - NonNullType(inner) -> option.Some(inner) 493 - _ -> option.None 494 - } 495 - } 496 - 497 - /// Get the kind of a type as a string for introspection 498 - pub fn type_kind(t: Type) -> String { 499 - case t { 500 - ScalarType(_) -> "SCALAR" 501 - ObjectType(_, _, _) -> "OBJECT" 502 - InputObjectType(_, _, _) -> "INPUT_OBJECT" 503 - EnumType(_, _, _) -> "ENUM" 504 - UnionType(_, _, _, _) -> "UNION" 505 - ListType(_) -> "LIST" 506 - NonNullType(_) -> "NON_NULL" 507 - } 508 - }
···
-256
graphql/src/graphql/sdl.gleam
··· 1 - /// GraphQL SDL (Schema Definition Language) Printer 2 - /// 3 - /// Generates proper SDL output from GraphQL schema types 4 - /// Follows the GraphQL specification for schema representation 5 - import gleam/list 6 - import gleam/option 7 - import gleam/string 8 - import graphql/schema 9 - 10 - /// Print a single GraphQL type as SDL 11 - pub fn print_type(type_: schema.Type) -> String { 12 - print_type_internal(type_, 0, False) 13 - } 14 - 15 - /// Print multiple GraphQL types as SDL with blank lines between them 16 - pub fn print_types(types: List(schema.Type)) -> String { 17 - list.map(types, print_type) 18 - |> string.join("\n\n") 19 - } 20 - 21 - // Internal function that handles indentation and inline mode 22 - fn print_type_internal( 23 - type_: schema.Type, 24 - indent_level: Int, 25 - inline: Bool, 26 - ) -> String { 27 - let kind = schema.type_kind(type_) 28 - 29 - case kind { 30 - "INPUT_OBJECT" -> print_input_object(type_, indent_level, inline) 31 - "OBJECT" -> print_object(type_, indent_level, inline) 32 - "ENUM" -> print_enum(type_, indent_level, inline) 33 - "UNION" -> print_union(type_, indent_level, inline) 34 - "SCALAR" -> print_scalar(type_, indent_level, inline) 35 - "LIST" -> { 36 - case schema.inner_type(type_) { 37 - option.Some(inner) -> 38 - "[" <> print_type_internal(inner, indent_level, True) <> "]" 39 - option.None -> "[Unknown]" 40 - } 41 - } 42 - "NON_NULL" -> { 43 - case schema.inner_type(type_) { 44 - option.Some(inner) -> 45 - print_type_internal(inner, indent_level, True) <> "!" 46 - option.None -> "Unknown!" 47 - } 48 - } 49 - _ -> schema.type_name(type_) 50 - } 51 - } 52 - 53 - fn print_scalar(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 54 - case inline { 55 - True -> schema.type_name(type_) 56 - False -> { 57 - let indent = string.repeat(" ", indent_level * 2) 58 - let description = schema.type_description(type_) 59 - let desc_block = case description { 60 - "" -> "" 61 - _ -> indent <> format_description(description) <> "\n" 62 - } 63 - 64 - desc_block <> indent <> "scalar " <> schema.type_name(type_) 65 - } 66 - } 67 - } 68 - 69 - fn print_union(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 70 - case inline { 71 - True -> schema.type_name(type_) 72 - False -> { 73 - let type_name = schema.type_name(type_) 74 - let indent = string.repeat(" ", indent_level * 2) 75 - let description = schema.type_description(type_) 76 - let desc_block = case description { 77 - "" -> "" 78 - _ -> indent <> format_description(description) <> "\n" 79 - } 80 - 81 - let possible_types = schema.get_possible_types(type_) 82 - let type_names = 83 - list.map(possible_types, fn(t) { schema.type_name(t) }) 84 - |> string.join(" | ") 85 - 86 - desc_block <> indent <> "union " <> type_name <> " = " <> type_names 87 - } 88 - } 89 - } 90 - 91 - fn print_input_object( 92 - type_: schema.Type, 93 - indent_level: Int, 94 - inline: Bool, 95 - ) -> String { 96 - case inline { 97 - True -> schema.type_name(type_) 98 - False -> { 99 - let type_name = schema.type_name(type_) 100 - let indent = string.repeat(" ", indent_level * 2) 101 - let field_indent = string.repeat(" ", { indent_level + 1 } * 2) 102 - 103 - let description = schema.type_description(type_) 104 - let desc_block = case description { 105 - "" -> "" 106 - _ -> indent <> format_description(description) <> "\n" 107 - } 108 - 109 - let fields = schema.get_input_fields(type_) 110 - 111 - let field_lines = 112 - list.map(fields, fn(field) { 113 - let field_name = schema.input_field_name(field) 114 - let field_type = schema.input_field_type(field) 115 - let field_desc = schema.input_field_description(field) 116 - let field_type_str = 117 - print_type_internal(field_type, indent_level + 1, True) 118 - 119 - let field_desc_block = case field_desc { 120 - "" -> "" 121 - _ -> field_indent <> format_description(field_desc) <> "\n" 122 - } 123 - 124 - field_desc_block 125 - <> field_indent 126 - <> field_name 127 - <> ": " 128 - <> field_type_str 129 - }) 130 - 131 - case list.is_empty(fields) { 132 - True -> desc_block <> indent <> "input " <> type_name <> " {}" 133 - False -> { 134 - desc_block 135 - <> indent 136 - <> "input " 137 - <> type_name 138 - <> " {\n" 139 - <> string.join(field_lines, "\n") 140 - <> "\n" 141 - <> indent 142 - <> "}" 143 - } 144 - } 145 - } 146 - } 147 - } 148 - 149 - fn print_object(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 150 - case inline { 151 - True -> schema.type_name(type_) 152 - False -> { 153 - let type_name = schema.type_name(type_) 154 - let indent = string.repeat(" ", indent_level * 2) 155 - let field_indent = string.repeat(" ", { indent_level + 1 } * 2) 156 - 157 - let description = schema.type_description(type_) 158 - let desc_block = case description { 159 - "" -> "" 160 - _ -> indent <> format_description(description) <> "\n" 161 - } 162 - 163 - let fields = schema.get_fields(type_) 164 - 165 - let field_lines = 166 - list.map(fields, fn(field) { 167 - let field_name = schema.field_name(field) 168 - let field_type = schema.field_type(field) 169 - let field_desc = schema.field_description(field) 170 - let field_type_str = 171 - print_type_internal(field_type, indent_level + 1, True) 172 - 173 - let field_desc_block = case field_desc { 174 - "" -> "" 175 - _ -> field_indent <> format_description(field_desc) <> "\n" 176 - } 177 - 178 - field_desc_block 179 - <> field_indent 180 - <> field_name 181 - <> ": " 182 - <> field_type_str 183 - }) 184 - 185 - case list.is_empty(fields) { 186 - True -> desc_block <> indent <> "type " <> type_name <> " {}" 187 - False -> { 188 - desc_block 189 - <> indent 190 - <> "type " 191 - <> type_name 192 - <> " {\n" 193 - <> string.join(field_lines, "\n") 194 - <> "\n" 195 - <> indent 196 - <> "}" 197 - } 198 - } 199 - } 200 - } 201 - } 202 - 203 - fn print_enum(type_: schema.Type, indent_level: Int, inline: Bool) -> String { 204 - case inline { 205 - True -> schema.type_name(type_) 206 - False -> { 207 - let type_name = schema.type_name(type_) 208 - let indent = string.repeat(" ", indent_level * 2) 209 - let value_indent = string.repeat(" ", { indent_level + 1 } * 2) 210 - 211 - let description = schema.type_description(type_) 212 - let desc_block = case description { 213 - "" -> "" 214 - _ -> indent <> format_description(description) <> "\n" 215 - } 216 - 217 - let values = schema.get_enum_values(type_) 218 - 219 - let value_lines = 220 - list.map(values, fn(value) { 221 - let value_name = schema.enum_value_name(value) 222 - let value_desc = schema.enum_value_description(value) 223 - 224 - let value_desc_block = case value_desc { 225 - "" -> "" 226 - _ -> value_indent <> format_description(value_desc) <> "\n" 227 - } 228 - 229 - value_desc_block <> value_indent <> value_name 230 - }) 231 - 232 - case list.is_empty(values) { 233 - True -> desc_block <> indent <> "enum " <> type_name <> " {}" 234 - False -> { 235 - desc_block 236 - <> indent 237 - <> "enum " 238 - <> type_name 239 - <> " {\n" 240 - <> string.join(value_lines, "\n") 241 - <> "\n" 242 - <> indent 243 - <> "}" 244 - } 245 - } 246 - } 247 - } 248 - } 249 - 250 - /// Format a description as a triple-quoted string 251 - fn format_description(description: String) -> String { 252 - case description { 253 - "" -> "" 254 - _ -> "\"\"\"" <> description <> "\"\"\"" 255 - } 256 - }
···
-32
graphql/src/graphql/value.gleam
··· 1 - /// GraphQL Value types 2 - /// 3 - /// Per GraphQL spec Section 2 - Language, values can be scalars, enums, 4 - /// lists, or objects. This module defines the core Value type used throughout 5 - /// the GraphQL implementation. 6 - /// A GraphQL value that can be used in queries, responses, and variables 7 - pub type Value { 8 - /// Represents null/absence of a value 9 - Null 10 - 11 - /// Integer value (32-bit signed integer per spec) 12 - Int(Int) 13 - 14 - /// Floating point value (IEEE 754 double precision per spec) 15 - Float(Float) 16 - 17 - /// UTF-8 string value 18 - String(String) 19 - 20 - /// Boolean true or false 21 - Boolean(Bool) 22 - 23 - /// Enum value represented as a string (e.g., "ACTIVE", "PENDING") 24 - Enum(String) 25 - 26 - /// Ordered list of values 27 - List(List(Value)) 28 - 29 - /// Unordered set of key-value pairs 30 - /// Using list of tuples for simplicity and ordering preservation 31 - Object(List(#(String, Value))) 32 - }
···
-867
graphql/test/executor_test.gleam
··· 1 - /// Tests for GraphQL Executor 2 - /// 3 - /// Tests query execution combining parser + schema + resolvers 4 - import birdie 5 - import gleam/dict 6 - import gleam/list 7 - import gleam/option.{None, Some} 8 - import gleam/string 9 - import gleeunit/should 10 - import graphql/executor 11 - import graphql/schema 12 - import graphql/value 13 - 14 - // Helper to create a simple test schema 15 - fn test_schema() -> schema.Schema { 16 - let query_type = 17 - schema.object_type("Query", "Root query type", [ 18 - schema.field("hello", schema.string_type(), "Hello field", fn(_ctx) { 19 - Ok(value.String("world")) 20 - }), 21 - schema.field("number", schema.int_type(), "Number field", fn(_ctx) { 22 - Ok(value.Int(42)) 23 - }), 24 - schema.field_with_args( 25 - "greet", 26 - schema.string_type(), 27 - "Greet someone", 28 - [schema.argument("name", schema.string_type(), "Name to greet", None)], 29 - fn(_ctx) { Ok(value.String("Hello, Alice!")) }, 30 - ), 31 - ]) 32 - 33 - schema.schema(query_type, None) 34 - } 35 - 36 - // Nested object schema for testing 37 - fn nested_schema() -> schema.Schema { 38 - let user_type = 39 - schema.object_type("User", "A user", [ 40 - schema.field("id", schema.id_type(), "User ID", fn(_ctx) { 41 - Ok(value.String("123")) 42 - }), 43 - schema.field("name", schema.string_type(), "User name", fn(_ctx) { 44 - Ok(value.String("Alice")) 45 - }), 46 - ]) 47 - 48 - let query_type = 49 - schema.object_type("Query", "Root query type", [ 50 - schema.field("user", user_type, "Get user", fn(_ctx) { 51 - Ok( 52 - value.Object([ 53 - #("id", value.String("123")), 54 - #("name", value.String("Alice")), 55 - ]), 56 - ) 57 - }), 58 - ]) 59 - 60 - schema.schema(query_type, None) 61 - } 62 - 63 - pub fn execute_simple_query_test() { 64 - let schema = test_schema() 65 - let query = "{ hello }" 66 - 67 - let result = executor.execute(query, schema, schema.context(None)) 68 - 69 - let response = case result { 70 - Ok(r) -> r 71 - Error(_) -> panic as "Execution failed" 72 - } 73 - 74 - birdie.snap(title: "Execute simple query", content: format_response(response)) 75 - } 76 - 77 - pub fn execute_multiple_fields_test() { 78 - let schema = test_schema() 79 - let query = "{ hello number }" 80 - 81 - let result = executor.execute(query, schema, schema.context(None)) 82 - 83 - should.be_ok(result) 84 - } 85 - 86 - pub fn execute_nested_query_test() { 87 - let schema = nested_schema() 88 - let query = "{ user { id name } }" 89 - 90 - let result = executor.execute(query, schema, schema.context(None)) 91 - 92 - should.be_ok(result) 93 - } 94 - 95 - // Helper to format response for snapshots 96 - fn format_response(response: executor.Response) -> String { 97 - string.inspect(response) 98 - } 99 - 100 - pub fn execute_field_with_arguments_test() { 101 - let schema = test_schema() 102 - let query = "{ greet(name: \"Alice\") }" 103 - 104 - let result = executor.execute(query, schema, schema.context(None)) 105 - 106 - should.be_ok(result) 107 - } 108 - 109 - pub fn execute_invalid_query_returns_error_test() { 110 - let schema = test_schema() 111 - let query = "{ invalid }" 112 - 113 - let result = executor.execute(query, schema, schema.context(None)) 114 - 115 - // Should return error since field doesn't exist 116 - case result { 117 - Ok(executor.Response(_, [_, ..])) -> should.be_true(True) 118 - Error(_) -> should.be_true(True) 119 - _ -> should.be_true(False) 120 - } 121 - } 122 - 123 - pub fn execute_parse_error_returns_error_test() { 124 - let schema = test_schema() 125 - let query = "{ invalid syntax" 126 - 127 - let result = executor.execute(query, schema, schema.context(None)) 128 - 129 - should.be_error(result) 130 - } 131 - 132 - pub fn execute_typename_introspection_test() { 133 - let schema = test_schema() 134 - let query = "{ __typename }" 135 - 136 - let result = executor.execute(query, schema, schema.context(None)) 137 - 138 - let response = case result { 139 - Ok(r) -> r 140 - Error(_) -> panic as "Execution failed" 141 - } 142 - 143 - birdie.snap( 144 - title: "Execute __typename introspection", 145 - content: format_response(response), 146 - ) 147 - } 148 - 149 - pub fn execute_typename_with_regular_fields_test() { 150 - let schema = test_schema() 151 - let query = "{ __typename hello }" 152 - 153 - let result = executor.execute(query, schema, schema.context(None)) 154 - 155 - let response = case result { 156 - Ok(r) -> r 157 - Error(_) -> panic as "Execution failed" 158 - } 159 - 160 - birdie.snap( 161 - title: "Execute __typename with regular fields", 162 - content: format_response(response), 163 - ) 164 - } 165 - 166 - pub fn execute_schema_introspection_query_type_test() { 167 - let schema = test_schema() 168 - let query = "{ __schema { queryType { name } } }" 169 - 170 - let result = executor.execute(query, schema, schema.context(None)) 171 - 172 - let response = case result { 173 - Ok(r) -> r 174 - Error(_) -> panic as "Execution failed" 175 - } 176 - 177 - birdie.snap( 178 - title: "Execute __schema introspection", 179 - content: format_response(response), 180 - ) 181 - } 182 - 183 - // Fragment execution tests 184 - pub fn execute_simple_fragment_spread_test() { 185 - let schema = nested_schema() 186 - let query = 187 - " 188 - fragment UserFields on User { 189 - id 190 - name 191 - } 192 - 193 - { user { ...UserFields } } 194 - " 195 - 196 - let result = executor.execute(query, schema, schema.context(None)) 197 - 198 - let response = case result { 199 - Ok(r) -> r 200 - Error(_) -> panic as "Execution failed" 201 - } 202 - 203 - birdie.snap( 204 - title: "Execute simple fragment spread", 205 - content: format_response(response), 206 - ) 207 - } 208 - 209 - // Test for list fields with nested selections 210 - pub fn execute_list_with_nested_selections_test() { 211 - // Create a schema with a list field 212 - let user_type = 213 - schema.object_type("User", "A user", [ 214 - schema.field("id", schema.id_type(), "User ID", fn(ctx) { 215 - case ctx.data { 216 - option.Some(value.Object(fields)) -> { 217 - case list.key_find(fields, "id") { 218 - Ok(id_val) -> Ok(id_val) 219 - Error(_) -> Ok(value.Null) 220 - } 221 - } 222 - _ -> Ok(value.Null) 223 - } 224 - }), 225 - schema.field("name", schema.string_type(), "User name", fn(ctx) { 226 - case ctx.data { 227 - option.Some(value.Object(fields)) -> { 228 - case list.key_find(fields, "name") { 229 - Ok(name_val) -> Ok(name_val) 230 - Error(_) -> Ok(value.Null) 231 - } 232 - } 233 - _ -> Ok(value.Null) 234 - } 235 - }), 236 - schema.field("email", schema.string_type(), "User email", fn(ctx) { 237 - case ctx.data { 238 - option.Some(value.Object(fields)) -> { 239 - case list.key_find(fields, "email") { 240 - Ok(email_val) -> Ok(email_val) 241 - Error(_) -> Ok(value.Null) 242 - } 243 - } 244 - _ -> Ok(value.Null) 245 - } 246 - }), 247 - ]) 248 - 249 - let list_type = schema.list_type(user_type) 250 - 251 - let query_type = 252 - schema.object_type("Query", "Root query type", [ 253 - schema.field("users", list_type, "Get all users", fn(_ctx) { 254 - // Return a list of user objects 255 - Ok( 256 - value.List([ 257 - value.Object([ 258 - #("id", value.String("1")), 259 - #("name", value.String("Alice")), 260 - #("email", value.String("alice@example.com")), 261 - ]), 262 - value.Object([ 263 - #("id", value.String("2")), 264 - #("name", value.String("Bob")), 265 - #("email", value.String("bob@example.com")), 266 - ]), 267 - ]), 268 - ) 269 - }), 270 - ]) 271 - 272 - let schema = schema.schema(query_type, None) 273 - 274 - // Query with nested field selection - only request id and name, not email 275 - let query = "{ users { id name } }" 276 - 277 - let result = executor.execute(query, schema, schema.context(None)) 278 - 279 - let response = case result { 280 - Ok(r) -> r 281 - Error(_) -> panic as "Execution failed" 282 - } 283 - 284 - birdie.snap( 285 - title: "Execute list with nested selections", 286 - content: format_response(response), 287 - ) 288 - } 289 - 290 - // Test that arguments are actually passed to resolvers 291 - pub fn execute_field_receives_string_argument_test() { 292 - let query_type = 293 - schema.object_type("Query", "Root", [ 294 - schema.field_with_args( 295 - "echo", 296 - schema.string_type(), 297 - "Echo the input", 298 - [schema.argument("message", schema.string_type(), "Message", None)], 299 - fn(ctx) { 300 - // Extract the argument from context 301 - case schema.get_argument(ctx, "message") { 302 - Some(value.String(msg)) -> Ok(value.String("Echo: " <> msg)) 303 - _ -> Ok(value.String("No message")) 304 - } 305 - }, 306 - ), 307 - ]) 308 - 309 - let test_schema = schema.schema(query_type, None) 310 - let query = "{ echo(message: \"hello\") }" 311 - 312 - let result = executor.execute(query, test_schema, schema.context(None)) 313 - 314 - let response = case result { 315 - Ok(r) -> r 316 - Error(_) -> panic as "Execution failed" 317 - } 318 - 319 - birdie.snap( 320 - title: "Execute field with string argument", 321 - content: format_response(response), 322 - ) 323 - } 324 - 325 - // Test list argument 326 - pub fn execute_field_receives_list_argument_test() { 327 - let query_type = 328 - schema.object_type("Query", "Root", [ 329 - schema.field_with_args( 330 - "sum", 331 - schema.int_type(), 332 - "Sum numbers", 333 - [ 334 - schema.argument( 335 - "numbers", 336 - schema.list_type(schema.int_type()), 337 - "Numbers", 338 - None, 339 - ), 340 - ], 341 - fn(ctx) { 342 - case schema.get_argument(ctx, "numbers") { 343 - Some(value.List(_items)) -> Ok(value.String("got list")) 344 - _ -> Ok(value.String("no list")) 345 - } 346 - }, 347 - ), 348 - ]) 349 - 350 - let test_schema = schema.schema(query_type, None) 351 - let query = "{ sum(numbers: [1, 2, 3]) }" 352 - 353 - let result = executor.execute(query, test_schema, schema.context(None)) 354 - 355 - should.be_ok(result) 356 - |> fn(response) { 357 - case response { 358 - executor.Response( 359 - data: value.Object([#("sum", value.String("got list"))]), 360 - errors: [], 361 - ) -> True 362 - _ -> False 363 - } 364 - } 365 - |> should.be_true 366 - } 367 - 368 - // Test object argument (like sortBy) 369 - pub fn execute_field_receives_object_argument_test() { 370 - let query_type = 371 - schema.object_type("Query", "Root", [ 372 - schema.field_with_args( 373 - "posts", 374 - schema.list_type(schema.string_type()), 375 - "Get posts", 376 - [ 377 - schema.argument( 378 - "sortBy", 379 - schema.list_type( 380 - schema.input_object_type("SortInput", "Sort", [ 381 - schema.input_field("field", schema.string_type(), "Field", None), 382 - schema.input_field( 383 - "direction", 384 - schema.enum_type("Direction", "Direction", [ 385 - schema.enum_value("ASC", "Ascending"), 386 - schema.enum_value("DESC", "Descending"), 387 - ]), 388 - "Direction", 389 - None, 390 - ), 391 - ]), 392 - ), 393 - "Sort order", 394 - None, 395 - ), 396 - ], 397 - fn(ctx) { 398 - case schema.get_argument(ctx, "sortBy") { 399 - Some(value.List([value.Object(fields), ..])) -> { 400 - case dict.from_list(fields) { 401 - fields_dict -> { 402 - case 403 - dict.get(fields_dict, "field"), 404 - dict.get(fields_dict, "direction") 405 - { 406 - Ok(value.String(field)), Ok(value.String(dir)) -> 407 - Ok(value.String("Sorting by " <> field <> " " <> dir)) 408 - _, _ -> Ok(value.String("Invalid sort")) 409 - } 410 - } 411 - } 412 - } 413 - _ -> Ok(value.String("No sort")) 414 - } 415 - }, 416 - ), 417 - ]) 418 - 419 - let test_schema = schema.schema(query_type, None) 420 - let query = "{ posts(sortBy: [{field: \"date\", direction: DESC}]) }" 421 - 422 - let result = executor.execute(query, test_schema, schema.context(None)) 423 - 424 - let response = case result { 425 - Ok(r) -> r 426 - Error(_) -> panic as "Execution failed" 427 - } 428 - 429 - birdie.snap( 430 - title: "Execute field with object argument", 431 - content: format_response(response), 432 - ) 433 - } 434 - 435 - // Variable resolution tests 436 - pub fn execute_query_with_variable_string_test() { 437 - let query_type = 438 - schema.object_type("Query", "Root query type", [ 439 - schema.field_with_args( 440 - "greet", 441 - schema.string_type(), 442 - "Greet someone", 443 - [ 444 - schema.argument("name", schema.string_type(), "Name to greet", None), 445 - ], 446 - fn(ctx) { 447 - case schema.get_argument(ctx, "name") { 448 - Some(value.String(name)) -> 449 - Ok(value.String("Hello, " <> name <> "!")) 450 - _ -> Ok(value.String("Hello, stranger!")) 451 - } 452 - }, 453 - ), 454 - ]) 455 - 456 - let test_schema = schema.schema(query_type, None) 457 - let query = "query Test($name: String!) { greet(name: $name) }" 458 - 459 - // Create context with variables 460 - let variables = dict.from_list([#("name", value.String("Alice"))]) 461 - let ctx = schema.context_with_variables(None, variables) 462 - 463 - let result = executor.execute(query, test_schema, ctx) 464 - 465 - let response = case result { 466 - Ok(r) -> r 467 - Error(_) -> panic as "Execution failed" 468 - } 469 - 470 - birdie.snap( 471 - title: "Execute query with string variable", 472 - content: format_response(response), 473 - ) 474 - } 475 - 476 - pub fn execute_query_with_variable_int_test() { 477 - let query_type = 478 - schema.object_type("Query", "Root query type", [ 479 - schema.field_with_args( 480 - "user", 481 - schema.string_type(), 482 - "Get user by ID", 483 - [ 484 - schema.argument("id", schema.int_type(), "User ID", None), 485 - ], 486 - fn(ctx) { 487 - case schema.get_argument(ctx, "id") { 488 - Some(value.Int(id)) -> 489 - Ok(value.String("User #" <> string.inspect(id))) 490 - _ -> Ok(value.String("Unknown user")) 491 - } 492 - }, 493 - ), 494 - ]) 495 - 496 - let test_schema = schema.schema(query_type, None) 497 - let query = "query GetUser($userId: Int!) { user(id: $userId) }" 498 - 499 - // Create context with variables 500 - let variables = dict.from_list([#("userId", value.Int(42))]) 501 - let ctx = schema.context_with_variables(None, variables) 502 - 503 - let result = executor.execute(query, test_schema, ctx) 504 - 505 - let response = case result { 506 - Ok(r) -> r 507 - Error(_) -> panic as "Execution failed" 508 - } 509 - 510 - birdie.snap( 511 - title: "Execute query with int variable", 512 - content: format_response(response), 513 - ) 514 - } 515 - 516 - pub fn execute_query_with_multiple_variables_test() { 517 - let query_type = 518 - schema.object_type("Query", "Root query type", [ 519 - schema.field_with_args( 520 - "search", 521 - schema.string_type(), 522 - "Search for something", 523 - [ 524 - schema.argument("query", schema.string_type(), "Search query", None), 525 - schema.argument("limit", schema.int_type(), "Max results", None), 526 - ], 527 - fn(ctx) { 528 - case 529 - schema.get_argument(ctx, "query"), 530 - schema.get_argument(ctx, "limit") 531 - { 532 - Some(value.String(q)), Some(value.Int(l)) -> 533 - Ok(value.String( 534 - "Searching for '" 535 - <> q 536 - <> "' (limit: " 537 - <> string.inspect(l) 538 - <> ")", 539 - )) 540 - _, _ -> Ok(value.String("Invalid search")) 541 - } 542 - }, 543 - ), 544 - ]) 545 - 546 - let test_schema = schema.schema(query_type, None) 547 - let query = 548 - "query Search($q: String!, $max: Int!) { search(query: $q, limit: $max) }" 549 - 550 - // Create context with variables 551 - let variables = 552 - dict.from_list([ 553 - #("q", value.String("graphql")), 554 - #("max", value.Int(10)), 555 - ]) 556 - let ctx = schema.context_with_variables(None, variables) 557 - 558 - let result = executor.execute(query, test_schema, ctx) 559 - 560 - let response = case result { 561 - Ok(r) -> r 562 - Error(_) -> panic as "Execution failed" 563 - } 564 - 565 - birdie.snap( 566 - title: "Execute query with multiple variables", 567 - content: format_response(response), 568 - ) 569 - } 570 - 571 - // Union type execution tests 572 - pub fn execute_union_with_inline_fragment_test() { 573 - // Create object types that will be part of the union 574 - let post_type = 575 - schema.object_type("Post", "A blog post", [ 576 - schema.field("title", schema.string_type(), "Post title", fn(ctx) { 577 - case ctx.data { 578 - option.Some(value.Object(fields)) -> { 579 - case list.key_find(fields, "title") { 580 - Ok(title_val) -> Ok(title_val) 581 - Error(_) -> Ok(value.Null) 582 - } 583 - } 584 - _ -> Ok(value.Null) 585 - } 586 - }), 587 - schema.field("content", schema.string_type(), "Post content", fn(ctx) { 588 - case ctx.data { 589 - option.Some(value.Object(fields)) -> { 590 - case list.key_find(fields, "content") { 591 - Ok(content_val) -> Ok(content_val) 592 - Error(_) -> Ok(value.Null) 593 - } 594 - } 595 - _ -> Ok(value.Null) 596 - } 597 - }), 598 - ]) 599 - 600 - let comment_type = 601 - schema.object_type("Comment", "A comment", [ 602 - schema.field("text", schema.string_type(), "Comment text", fn(ctx) { 603 - case ctx.data { 604 - option.Some(value.Object(fields)) -> { 605 - case list.key_find(fields, "text") { 606 - Ok(text_val) -> Ok(text_val) 607 - Error(_) -> Ok(value.Null) 608 - } 609 - } 610 - _ -> Ok(value.Null) 611 - } 612 - }), 613 - ]) 614 - 615 - // Type resolver that examines the __typename field 616 - let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 617 - case ctx.data { 618 - option.Some(value.Object(fields)) -> { 619 - case list.key_find(fields, "__typename") { 620 - Ok(value.String(type_name)) -> Ok(type_name) 621 - _ -> Error("No __typename field found") 622 - } 623 - } 624 - _ -> Error("No data") 625 - } 626 - } 627 - 628 - // Create union type 629 - let search_result_union = 630 - schema.union_type( 631 - "SearchResult", 632 - "A search result", 633 - [post_type, comment_type], 634 - type_resolver, 635 - ) 636 - 637 - // Create query type with a field returning the union 638 - let query_type = 639 - schema.object_type("Query", "Root query type", [ 640 - schema.field( 641 - "search", 642 - search_result_union, 643 - "Search for content", 644 - fn(_ctx) { 645 - // Return a Post 646 - Ok( 647 - value.Object([ 648 - #("__typename", value.String("Post")), 649 - #("title", value.String("GraphQL is awesome")), 650 - #("content", value.String("Learn all about GraphQL...")), 651 - ]), 652 - ) 653 - }, 654 - ), 655 - ]) 656 - 657 - let test_schema = schema.schema(query_type, None) 658 - 659 - // Query with inline fragment 660 - let query = 661 - " 662 - { 663 - search { 664 - ... on Post { 665 - title 666 - content 667 - } 668 - ... on Comment { 669 - text 670 - } 671 - } 672 - } 673 - " 674 - 675 - let result = executor.execute(query, test_schema, schema.context(None)) 676 - 677 - let response = case result { 678 - Ok(r) -> r 679 - Error(_) -> panic as "Execution failed" 680 - } 681 - 682 - birdie.snap( 683 - title: "Execute union with inline fragment", 684 - content: format_response(response), 685 - ) 686 - } 687 - 688 - pub fn execute_union_list_with_inline_fragments_test() { 689 - // Create object types 690 - let post_type = 691 - schema.object_type("Post", "A blog post", [ 692 - schema.field("title", schema.string_type(), "Post title", fn(ctx) { 693 - case ctx.data { 694 - option.Some(value.Object(fields)) -> { 695 - case list.key_find(fields, "title") { 696 - Ok(title_val) -> Ok(title_val) 697 - Error(_) -> Ok(value.Null) 698 - } 699 - } 700 - _ -> Ok(value.Null) 701 - } 702 - }), 703 - ]) 704 - 705 - let comment_type = 706 - schema.object_type("Comment", "A comment", [ 707 - schema.field("text", schema.string_type(), "Comment text", fn(ctx) { 708 - case ctx.data { 709 - option.Some(value.Object(fields)) -> { 710 - case list.key_find(fields, "text") { 711 - Ok(text_val) -> Ok(text_val) 712 - Error(_) -> Ok(value.Null) 713 - } 714 - } 715 - _ -> Ok(value.Null) 716 - } 717 - }), 718 - ]) 719 - 720 - // Type resolver 721 - let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 722 - case ctx.data { 723 - option.Some(value.Object(fields)) -> { 724 - case list.key_find(fields, "__typename") { 725 - Ok(value.String(type_name)) -> Ok(type_name) 726 - _ -> Error("No __typename field found") 727 - } 728 - } 729 - _ -> Error("No data") 730 - } 731 - } 732 - 733 - // Create union type 734 - let search_result_union = 735 - schema.union_type( 736 - "SearchResult", 737 - "A search result", 738 - [post_type, comment_type], 739 - type_resolver, 740 - ) 741 - 742 - // Create query type with a list of unions 743 - let query_type = 744 - schema.object_type("Query", "Root query type", [ 745 - schema.field( 746 - "searchAll", 747 - schema.list_type(search_result_union), 748 - "Search for all content", 749 - fn(_ctx) { 750 - // Return a list with mixed types 751 - Ok( 752 - value.List([ 753 - value.Object([ 754 - #("__typename", value.String("Post")), 755 - #("title", value.String("First Post")), 756 - ]), 757 - value.Object([ 758 - #("__typename", value.String("Comment")), 759 - #("text", value.String("Great article!")), 760 - ]), 761 - value.Object([ 762 - #("__typename", value.String("Post")), 763 - #("title", value.String("Second Post")), 764 - ]), 765 - ]), 766 - ) 767 - }, 768 - ), 769 - ]) 770 - 771 - let test_schema = schema.schema(query_type, None) 772 - 773 - // Query with inline fragments on list items 774 - let query = 775 - " 776 - { 777 - searchAll { 778 - ... on Post { 779 - title 780 - } 781 - ... on Comment { 782 - text 783 - } 784 - } 785 - } 786 - " 787 - 788 - let result = executor.execute(query, test_schema, schema.context(None)) 789 - 790 - let response = case result { 791 - Ok(r) -> r 792 - Error(_) -> panic as "Execution failed" 793 - } 794 - 795 - birdie.snap( 796 - title: "Execute union list with inline fragments", 797 - content: format_response(response), 798 - ) 799 - } 800 - 801 - // Test field aliases 802 - pub fn execute_field_with_alias_test() { 803 - let schema = test_schema() 804 - let query = "{ greeting: hello }" 805 - 806 - let result = executor.execute(query, schema, schema.context(None)) 807 - 808 - let response = case result { 809 - Ok(r) -> r 810 - Error(_) -> panic as "Execution failed" 811 - } 812 - 813 - // Response should contain "greeting" as the key, not "hello" 814 - case response.data { 815 - value.Object(fields) -> { 816 - case list.key_find(fields, "greeting") { 817 - Ok(_) -> should.be_true(True) 818 - Error(_) -> { 819 - // Check if it incorrectly used "hello" instead 820 - case list.key_find(fields, "hello") { 821 - Ok(_) -> 822 - panic as "Alias not applied - used 'hello' instead of 'greeting'" 823 - Error(_) -> 824 - panic as "Neither 'greeting' nor 'hello' found in response" 825 - } 826 - } 827 - } 828 - } 829 - _ -> panic as "Expected object response" 830 - } 831 - } 832 - 833 - // Test multiple aliases 834 - pub fn execute_multiple_fields_with_aliases_test() { 835 - let schema = test_schema() 836 - let query = "{ greeting: hello num: number }" 837 - 838 - let result = executor.execute(query, schema, schema.context(None)) 839 - 840 - let response = case result { 841 - Ok(r) -> r 842 - Error(_) -> panic as "Execution failed" 843 - } 844 - 845 - birdie.snap( 846 - title: "Execute multiple fields with aliases", 847 - content: format_response(response), 848 - ) 849 - } 850 - 851 - // Test mixed aliased and non-aliased fields 852 - pub fn execute_mixed_aliased_fields_test() { 853 - let schema = test_schema() 854 - let query = "{ greeting: hello number }" 855 - 856 - let result = executor.execute(query, schema, schema.context(None)) 857 - 858 - let response = case result { 859 - Ok(r) -> r 860 - Error(_) -> panic as "Execution failed" 861 - } 862 - 863 - birdie.snap( 864 - title: "Execute mixed aliased and non-aliased fields", 865 - content: format_response(response), 866 - ) 867 - }
···
-5
graphql/test/graphql_test.gleam
··· 1 - import gleeunit 2 - 3 - pub fn main() -> Nil { 4 - gleeunit.main() 5 - }
···
-676
graphql/test/introspection_test.gleam
··· 1 - /// Tests for GraphQL Introspection 2 - /// 3 - /// Comprehensive tests for introspection queries 4 - import gleam/list 5 - import gleam/option.{None} 6 - import gleeunit/should 7 - import graphql/executor 8 - import graphql/schema 9 - import graphql/value 10 - 11 - // Helper to create a simple test schema 12 - fn test_schema() -> schema.Schema { 13 - let query_type = 14 - schema.object_type("Query", "Root query type", [ 15 - schema.field("hello", schema.string_type(), "Hello field", fn(_ctx) { 16 - Ok(value.String("world")) 17 - }), 18 - schema.field("number", schema.int_type(), "Number field", fn(_ctx) { 19 - Ok(value.Int(42)) 20 - }), 21 - ]) 22 - 23 - schema.schema(query_type, None) 24 - } 25 - 26 - /// Test: Multiple scalar fields on __schema 27 - /// This test verifies that all requested fields on __schema are returned 28 - pub fn schema_multiple_fields_test() { 29 - let schema = test_schema() 30 - let query = 31 - "{ __schema { queryType { name } mutationType { name } subscriptionType { name } } }" 32 - 33 - let result = executor.execute(query, schema, schema.context(None)) 34 - 35 - should.be_ok(result) 36 - |> fn(response) { 37 - case response { 38 - executor.Response(data: value.Object(fields), errors: []) -> { 39 - // Check that we have __schema field 40 - case list.key_find(fields, "__schema") { 41 - Ok(value.Object(schema_fields)) -> { 42 - // Check for all three fields 43 - let has_query_type = case 44 - list.key_find(schema_fields, "queryType") 45 - { 46 - Ok(value.Object(_)) -> True 47 - _ -> False 48 - } 49 - let has_mutation_type = case 50 - list.key_find(schema_fields, "mutationType") 51 - { 52 - Ok(value.Null) -> True 53 - // Should be null 54 - _ -> False 55 - } 56 - let has_subscription_type = case 57 - list.key_find(schema_fields, "subscriptionType") 58 - { 59 - Ok(value.Null) -> True 60 - // Should be null 61 - _ -> False 62 - } 63 - has_query_type && has_mutation_type && has_subscription_type 64 - } 65 - _ -> False 66 - } 67 - } 68 - _ -> False 69 - } 70 - } 71 - |> should.be_true 72 - } 73 - 74 - /// Test: types field with other fields 75 - /// Verifies that the types array is returned along with other fields 76 - pub fn schema_types_with_other_fields_test() { 77 - let schema = test_schema() 78 - let query = "{ __schema { queryType { name } types { name } } }" 79 - 80 - let result = executor.execute(query, schema, schema.context(None)) 81 - 82 - should.be_ok(result) 83 - |> fn(response) { 84 - case response { 85 - executor.Response(data: value.Object(fields), errors: []) -> { 86 - case list.key_find(fields, "__schema") { 87 - Ok(value.Object(schema_fields)) -> { 88 - // Check for both fields 89 - let has_query_type = case 90 - list.key_find(schema_fields, "queryType") 91 - { 92 - Ok(value.Object(qt_fields)) -> { 93 - case list.key_find(qt_fields, "name") { 94 - Ok(value.String("Query")) -> True 95 - _ -> False 96 - } 97 - } 98 - _ -> False 99 - } 100 - let has_types = case list.key_find(schema_fields, "types") { 101 - Ok(value.List(types)) -> { 102 - // Should have 6 types: Query + 5 scalars 103 - list.length(types) == 6 104 - } 105 - _ -> False 106 - } 107 - has_query_type && has_types 108 - } 109 - _ -> False 110 - } 111 - } 112 - _ -> False 113 - } 114 - } 115 - |> should.be_true 116 - } 117 - 118 - /// Test: All __schema top-level fields 119 - /// Verifies that a query with all possible __schema fields returns all of them 120 - pub fn schema_all_fields_test() { 121 - let schema = test_schema() 122 - let query = 123 - "{ __schema { queryType { name } mutationType { name } subscriptionType { name } types { name } directives { name } } }" 124 - 125 - let result = executor.execute(query, schema, schema.context(None)) 126 - 127 - should.be_ok(result) 128 - |> fn(response) { 129 - case response { 130 - executor.Response(data: value.Object(fields), errors: []) -> { 131 - case list.key_find(fields, "__schema") { 132 - Ok(value.Object(schema_fields)) -> { 133 - // Check for all five fields 134 - let field_count = list.length(schema_fields) 135 - // Should have exactly 5 fields 136 - field_count == 5 137 - } 138 - _ -> False 139 - } 140 - } 141 - _ -> False 142 - } 143 - } 144 - |> should.be_true 145 - } 146 - 147 - /// Test: Field order doesn't matter 148 - /// Verifies that field order in the query doesn't affect results 149 - pub fn schema_field_order_test() { 150 - let schema = test_schema() 151 - let query1 = "{ __schema { types { name } queryType { name } } }" 152 - let query2 = "{ __schema { queryType { name } types { name } } }" 153 - 154 - let result1 = executor.execute(query1, schema, schema.context(None)) 155 - let result2 = executor.execute(query2, schema, schema.context(None)) 156 - 157 - // Both should succeed 158 - should.be_ok(result1) 159 - should.be_ok(result2) 160 - 161 - // Both should have the same fields 162 - case result1, result2 { 163 - Ok(executor.Response(data: value.Object(fields1), errors: [])), 164 - Ok(executor.Response(data: value.Object(fields2), errors: [])) 165 - -> { 166 - case 167 - list.key_find(fields1, "__schema"), 168 - list.key_find(fields2, "__schema") 169 - { 170 - Ok(value.Object(schema_fields1)), Ok(value.Object(schema_fields2)) -> { 171 - let count1 = list.length(schema_fields1) 172 - let count2 = list.length(schema_fields2) 173 - // Both should have 2 fields 174 - count1 == 2 && count2 == 2 175 - } 176 - _, _ -> False 177 - } 178 - } 179 - _, _ -> False 180 - } 181 - |> should.be_true 182 - } 183 - 184 - /// Test: Nested introspection on types 185 - /// Verifies that nested field selections work correctly 186 - pub fn schema_types_nested_fields_test() { 187 - let schema = test_schema() 188 - let query = "{ __schema { types { name kind fields { name } } } }" 189 - 190 - let result = executor.execute(query, schema, schema.context(None)) 191 - 192 - should.be_ok(result) 193 - |> fn(response) { 194 - case response { 195 - executor.Response(data: value.Object(fields), errors: []) -> { 196 - case list.key_find(fields, "__schema") { 197 - Ok(value.Object(schema_fields)) -> { 198 - case list.key_find(schema_fields, "types") { 199 - Ok(value.List(types)) -> { 200 - // Check that each type has name, kind, and fields 201 - list.all(types, fn(type_val) { 202 - case type_val { 203 - value.Object(type_fields) -> { 204 - let has_name = case list.key_find(type_fields, "name") { 205 - Ok(_) -> True 206 - _ -> False 207 - } 208 - let has_kind = case list.key_find(type_fields, "kind") { 209 - Ok(_) -> True 210 - _ -> False 211 - } 212 - let has_fields = case 213 - list.key_find(type_fields, "fields") 214 - { 215 - Ok(_) -> True 216 - // Can be null or list 217 - _ -> False 218 - } 219 - has_name && has_kind && has_fields 220 - } 221 - _ -> False 222 - } 223 - }) 224 - } 225 - _ -> False 226 - } 227 - } 228 - _ -> False 229 - } 230 - } 231 - _ -> False 232 - } 233 - } 234 - |> should.be_true 235 - } 236 - 237 - /// Test: Empty nested selections on null fields 238 - /// Verifies that querying nested fields on null values doesn't cause errors 239 - pub fn schema_null_field_with_deep_nesting_test() { 240 - let schema = test_schema() 241 - let query = "{ __schema { mutationType { name fields { name } } } }" 242 - 243 - let result = executor.execute(query, schema, schema.context(None)) 244 - 245 - should.be_ok(result) 246 - |> fn(response) { 247 - case response { 248 - executor.Response(data: value.Object(fields), errors: []) -> { 249 - case list.key_find(fields, "__schema") { 250 - Ok(value.Object(schema_fields)) -> { 251 - case list.key_find(schema_fields, "mutationType") { 252 - Ok(value.Null) -> True 253 - // Should be null, not error 254 - _ -> False 255 - } 256 - } 257 - _ -> False 258 - } 259 - } 260 - _ -> False 261 - } 262 - } 263 - |> should.be_true 264 - } 265 - 266 - /// Test: Inline fragments in introspection 267 - /// Verifies that inline fragments work correctly in introspection queries (like GraphiQL uses) 268 - pub fn schema_inline_fragment_test() { 269 - let schema = test_schema() 270 - let query = "{ __schema { types { ... on __Type { kind name } } } }" 271 - 272 - let result = executor.execute(query, schema, schema.context(None)) 273 - 274 - should.be_ok(result) 275 - |> fn(response) { 276 - case response { 277 - executor.Response(data: value.Object(fields), errors: []) -> { 278 - case list.key_find(fields, "__schema") { 279 - Ok(value.Object(schema_fields)) -> { 280 - case list.key_find(schema_fields, "types") { 281 - Ok(value.List(types)) -> { 282 - // Should have 6 types with kind and name fields 283 - list.length(types) == 6 284 - && list.all(types, fn(type_val) { 285 - case type_val { 286 - value.Object(type_fields) -> { 287 - let has_kind = case list.key_find(type_fields, "kind") { 288 - Ok(value.String(_)) -> True 289 - _ -> False 290 - } 291 - let has_name = case list.key_find(type_fields, "name") { 292 - Ok(value.String(_)) -> True 293 - _ -> False 294 - } 295 - has_kind && has_name 296 - } 297 - _ -> False 298 - } 299 - }) 300 - } 301 - _ -> False 302 - } 303 - } 304 - _ -> False 305 - } 306 - } 307 - _ -> False 308 - } 309 - } 310 - |> should.be_true 311 - } 312 - 313 - /// Test: Basic __type query 314 - /// Verifies that __type(name: "TypeName") returns the correct type 315 - pub fn type_basic_query_test() { 316 - let schema = test_schema() 317 - let query = "{ __type(name: \"Query\") { name kind } }" 318 - 319 - let result = executor.execute(query, schema, schema.context(None)) 320 - 321 - should.be_ok(result) 322 - |> fn(response) { 323 - case response { 324 - executor.Response(data: value.Object(fields), errors: []) -> { 325 - case list.key_find(fields, "__type") { 326 - Ok(value.Object(type_fields)) -> { 327 - // Check name and kind 328 - let has_correct_name = case list.key_find(type_fields, "name") { 329 - Ok(value.String("Query")) -> True 330 - _ -> False 331 - } 332 - let has_correct_kind = case list.key_find(type_fields, "kind") { 333 - Ok(value.String("OBJECT")) -> True 334 - _ -> False 335 - } 336 - has_correct_name && has_correct_kind 337 - } 338 - _ -> False 339 - } 340 - } 341 - _ -> False 342 - } 343 - } 344 - |> should.be_true 345 - } 346 - 347 - /// Test: __type query with nested fields 348 - /// Verifies that nested selections work correctly on __type 349 - pub fn type_nested_fields_test() { 350 - let schema = test_schema() 351 - let query = 352 - "{ __type(name: \"Query\") { name kind fields { name type { name kind } } } }" 353 - 354 - let result = executor.execute(query, schema, schema.context(None)) 355 - 356 - should.be_ok(result) 357 - |> fn(response) { 358 - case response { 359 - executor.Response(data: value.Object(fields), errors: []) -> { 360 - case list.key_find(fields, "__type") { 361 - Ok(value.Object(type_fields)) -> { 362 - // Check that fields exists and is a list 363 - case list.key_find(type_fields, "fields") { 364 - Ok(value.List(field_list)) -> { 365 - // Should have 2 fields (hello and number) 366 - list.length(field_list) == 2 367 - && list.all(field_list, fn(field_val) { 368 - case field_val { 369 - value.Object(field_fields) -> { 370 - let has_name = case list.key_find(field_fields, "name") { 371 - Ok(value.String(_)) -> True 372 - _ -> False 373 - } 374 - let has_type = case list.key_find(field_fields, "type") { 375 - Ok(value.Object(_)) -> True 376 - _ -> False 377 - } 378 - has_name && has_type 379 - } 380 - _ -> False 381 - } 382 - }) 383 - } 384 - _ -> False 385 - } 386 - } 387 - _ -> False 388 - } 389 - } 390 - _ -> False 391 - } 392 - } 393 - |> should.be_true 394 - } 395 - 396 - /// Test: __type query for scalar types 397 - /// Verifies that __type works for built-in scalar types 398 - pub fn type_scalar_query_test() { 399 - let schema = test_schema() 400 - let query = "{ __type(name: \"String\") { name kind } }" 401 - 402 - let result = executor.execute(query, schema, schema.context(None)) 403 - 404 - should.be_ok(result) 405 - |> fn(response) { 406 - case response { 407 - executor.Response(data: value.Object(fields), errors: []) -> { 408 - case list.key_find(fields, "__type") { 409 - Ok(value.Object(type_fields)) -> { 410 - // Check name and kind 411 - let has_correct_name = case list.key_find(type_fields, "name") { 412 - Ok(value.String("String")) -> True 413 - _ -> False 414 - } 415 - let has_correct_kind = case list.key_find(type_fields, "kind") { 416 - Ok(value.String("SCALAR")) -> True 417 - _ -> False 418 - } 419 - has_correct_name && has_correct_kind 420 - } 421 - _ -> False 422 - } 423 - } 424 - _ -> False 425 - } 426 - } 427 - |> should.be_true 428 - } 429 - 430 - /// Test: __type query for non-existent type 431 - /// Verifies that __type returns null for types that don't exist 432 - pub fn type_not_found_test() { 433 - let schema = test_schema() 434 - let query = "{ __type(name: \"NonExistentType\") { name kind } }" 435 - 436 - let result = executor.execute(query, schema, schema.context(None)) 437 - 438 - should.be_ok(result) 439 - |> fn(response) { 440 - case response { 441 - executor.Response(data: value.Object(fields), errors: []) -> { 442 - case list.key_find(fields, "__type") { 443 - Ok(value.Null) -> True 444 - _ -> False 445 - } 446 - } 447 - _ -> False 448 - } 449 - } 450 - |> should.be_true 451 - } 452 - 453 - /// Test: __type query without name argument 454 - /// Verifies that __type returns an error when name argument is missing 455 - pub fn type_missing_argument_test() { 456 - let schema = test_schema() 457 - let query = "{ __type { name kind } }" 458 - 459 - let result = executor.execute(query, schema, schema.context(None)) 460 - 461 - should.be_ok(result) 462 - |> fn(response) { 463 - case response { 464 - executor.Response(data: value.Object(fields), errors: errors) -> { 465 - // Should have __type field as null 466 - let has_null_type = case list.key_find(fields, "__type") { 467 - Ok(value.Null) -> True 468 - _ -> False 469 - } 470 - // Should have an error 471 - let has_error = errors != [] 472 - has_null_type && has_error 473 - } 474 - _ -> False 475 - } 476 - } 477 - |> should.be_true 478 - } 479 - 480 - /// Test: Combined __type and __schema query 481 - /// Verifies that __type and __schema can be queried together 482 - pub fn type_and_schema_combined_test() { 483 - let schema = test_schema() 484 - let query = 485 - "{ __schema { queryType { name } } __type(name: \"String\") { name kind } }" 486 - 487 - let result = executor.execute(query, schema, schema.context(None)) 488 - 489 - should.be_ok(result) 490 - |> fn(response) { 491 - case response { 492 - executor.Response(data: value.Object(fields), errors: []) -> { 493 - let has_schema = case list.key_find(fields, "__schema") { 494 - Ok(value.Object(_)) -> True 495 - _ -> False 496 - } 497 - let has_type = case list.key_find(fields, "__type") { 498 - Ok(value.Object(_)) -> True 499 - _ -> False 500 - } 501 - has_schema && has_type 502 - } 503 - _ -> False 504 - } 505 - } 506 - |> should.be_true 507 - } 508 - 509 - /// Test: Deep introspection queries complete without hanging 510 - /// This test verifies that the cycle detection prevents infinite loops 511 - /// by successfully completing a deeply nested introspection query 512 - pub fn deep_introspection_test() { 513 - let schema = test_schema() 514 - 515 - // Query with deep nesting including ofType chains 516 - // Without cycle detection, this could cause infinite loops 517 - let query = 518 - "{ __schema { types { name kind fields { name type { name kind ofType { name kind ofType { name } } } } } } }" 519 - 520 - let result = executor.execute(query, schema, schema.context(None)) 521 - 522 - // The key test: should complete without hanging 523 - should.be_ok(result) 524 - |> fn(response) { 525 - case response { 526 - executor.Response(data: value.Object(fields), errors: _errors) -> { 527 - // Should have __schema field with types 528 - case list.key_find(fields, "__schema") { 529 - Ok(value.Object(schema_fields)) -> { 530 - case list.key_find(schema_fields, "types") { 531 - Ok(value.List(types)) -> types != [] 532 - _ -> False 533 - } 534 - } 535 - _ -> False 536 - } 537 - } 538 - _ -> False 539 - } 540 - } 541 - |> should.be_true 542 - } 543 - 544 - /// Test: Fragment spreads work in introspection queries 545 - /// Verifies that fragment spreads like those used by GraphiQL work correctly 546 - pub fn introspection_fragment_spread_test() { 547 - // Create a schema with an ENUM type 548 - let sort_enum = 549 - schema.enum_type("SortDirection", "Sort direction", [ 550 - schema.enum_value("ASC", "Ascending"), 551 - schema.enum_value("DESC", "Descending"), 552 - ]) 553 - 554 - let query_type = 555 - schema.object_type("Query", "Root query", [ 556 - schema.field("items", schema.list_type(schema.string_type()), "", fn(_) { 557 - Ok(value.List([value.String("a"), value.String("b")])) 558 - }), 559 - schema.field("sort", sort_enum, "", fn(_) { Ok(value.String("ASC")) }), 560 - ]) 561 - 562 - let test_schema = schema.schema(query_type, None) 563 - 564 - // Use a fragment spread like GraphiQL does 565 - let query = 566 - " 567 - query IntrospectionQuery { 568 - __schema { 569 - types { 570 - ...FullType 571 - } 572 - } 573 - } 574 - 575 - fragment FullType on __Type { 576 - kind 577 - name 578 - enumValues(includeDeprecated: true) { 579 - name 580 - description 581 - } 582 - } 583 - " 584 - 585 - let result = executor.execute(query, test_schema, schema.context(None)) 586 - 587 - should.be_ok(result) 588 - |> fn(response) { 589 - case response { 590 - executor.Response(data: value.Object(fields), errors: _) -> { 591 - case list.key_find(fields, "__schema") { 592 - Ok(value.Object(schema_fields)) -> { 593 - case list.key_find(schema_fields, "types") { 594 - Ok(value.List(types)) -> { 595 - // Find the SortDirection enum 596 - let enum_type = 597 - list.find(types, fn(t) { 598 - case t { 599 - value.Object(type_fields) -> { 600 - case list.key_find(type_fields, "name") { 601 - Ok(value.String("SortDirection")) -> True 602 - _ -> False 603 - } 604 - } 605 - _ -> False 606 - } 607 - }) 608 - 609 - case enum_type { 610 - Ok(value.Object(type_fields)) -> { 611 - // Should have kind field from fragment 612 - let has_kind = case list.key_find(type_fields, "kind") { 613 - Ok(value.String("ENUM")) -> True 614 - _ -> False 615 - } 616 - 617 - // Should have enumValues field from fragment 618 - let has_enum_values = case 619 - list.key_find(type_fields, "enumValues") 620 - { 621 - Ok(value.List(values)) -> list.length(values) == 2 622 - _ -> False 623 - } 624 - 625 - has_kind && has_enum_values 626 - } 627 - _ -> False 628 - } 629 - } 630 - _ -> False 631 - } 632 - } 633 - _ -> False 634 - } 635 - } 636 - _ -> False 637 - } 638 - } 639 - |> should.be_true 640 - } 641 - 642 - /// Test: Simple fragment on __type 643 - pub fn simple_type_fragment_test() { 644 - let schema = test_schema() 645 - 646 - let query = 647 - "{ __type(name: \"Query\") { ...TypeFrag } } fragment TypeFrag on __Type { name kind }" 648 - 649 - let result = executor.execute(query, schema, schema.context(None)) 650 - 651 - should.be_ok(result) 652 - |> fn(response) { 653 - case response { 654 - executor.Response(data: value.Object(fields), errors: _) -> { 655 - case list.key_find(fields, "__type") { 656 - Ok(value.Object(type_fields)) -> { 657 - // Check if we got an error about fragment not found 658 - case list.key_find(type_fields, "__FRAGMENT_ERROR") { 659 - Ok(value.String(msg)) -> { 660 - // Fragment wasn't found 661 - panic as msg 662 - } 663 - _ -> { 664 - // No error, check if we have actual fields 665 - type_fields != [] 666 - } 667 - } 668 - } 669 - _ -> False 670 - } 671 - } 672 - _ -> False 673 - } 674 - } 675 - |> should.be_true 676 - }
···
-233
graphql/test/lexer_test.gleam
··· 1 - /// Tests for GraphQL Lexer (tokenization) 2 - /// 3 - /// GraphQL spec Section 2 - Language 4 - /// Token types: Punctuator, Name, IntValue, FloatValue, StringValue 5 - /// Ignored: Whitespace, LineTerminator, Comment, Comma 6 - import gleeunit/should 7 - import graphql/lexer.{ 8 - BraceClose, BraceOpen, Colon, Dollar, Exclamation, Float, Int, Name, 9 - ParenClose, ParenOpen, String, 10 - } 11 - 12 - // Punctuator tests 13 - pub fn tokenize_brace_open_test() { 14 - lexer.tokenize("{") 15 - |> should.equal(Ok([BraceOpen])) 16 - } 17 - 18 - pub fn tokenize_brace_close_test() { 19 - lexer.tokenize("}") 20 - |> should.equal(Ok([BraceClose])) 21 - } 22 - 23 - pub fn tokenize_paren_open_test() { 24 - lexer.tokenize("(") 25 - |> should.equal(Ok([ParenOpen])) 26 - } 27 - 28 - pub fn tokenize_paren_close_test() { 29 - lexer.tokenize(")") 30 - |> should.equal(Ok([ParenClose])) 31 - } 32 - 33 - pub fn tokenize_colon_test() { 34 - lexer.tokenize(":") 35 - |> should.equal(Ok([Colon])) 36 - } 37 - 38 - pub fn tokenize_exclamation_test() { 39 - lexer.tokenize("!") 40 - |> should.equal(Ok([Exclamation])) 41 - } 42 - 43 - pub fn tokenize_dollar_test() { 44 - lexer.tokenize("$") 45 - |> should.equal(Ok([Dollar])) 46 - } 47 - 48 - // Name tests (identifiers) 49 - pub fn tokenize_simple_name_test() { 50 - lexer.tokenize("query") 51 - |> should.equal(Ok([Name("query")])) 52 - } 53 - 54 - pub fn tokenize_name_with_underscore_test() { 55 - lexer.tokenize("user_name") 56 - |> should.equal(Ok([Name("user_name")])) 57 - } 58 - 59 - pub fn tokenize_name_with_numbers_test() { 60 - lexer.tokenize("field123") 61 - |> should.equal(Ok([Name("field123")])) 62 - } 63 - 64 - // Int value tests 65 - pub fn tokenize_positive_int_test() { 66 - lexer.tokenize("42") 67 - |> should.equal(Ok([Int("42")])) 68 - } 69 - 70 - pub fn tokenize_negative_int_test() { 71 - lexer.tokenize("-42") 72 - |> should.equal(Ok([Int("-42")])) 73 - } 74 - 75 - pub fn tokenize_zero_test() { 76 - lexer.tokenize("0") 77 - |> should.equal(Ok([Int("0")])) 78 - } 79 - 80 - // Float value tests 81 - pub fn tokenize_simple_float_test() { 82 - lexer.tokenize("3.14") 83 - |> should.equal(Ok([Float("3.14")])) 84 - } 85 - 86 - pub fn tokenize_negative_float_test() { 87 - lexer.tokenize("-3.14") 88 - |> should.equal(Ok([Float("-3.14")])) 89 - } 90 - 91 - pub fn tokenize_float_with_exponent_test() { 92 - lexer.tokenize("1.5e10") 93 - |> should.equal(Ok([Float("1.5e10")])) 94 - } 95 - 96 - pub fn tokenize_float_with_negative_exponent_test() { 97 - lexer.tokenize("1.5e-10") 98 - |> should.equal(Ok([Float("1.5e-10")])) 99 - } 100 - 101 - // String value tests 102 - pub fn tokenize_empty_string_test() { 103 - lexer.tokenize("\"\"") 104 - |> should.equal(Ok([String("")])) 105 - } 106 - 107 - pub fn tokenize_simple_string_test() { 108 - lexer.tokenize("\"hello\"") 109 - |> should.equal(Ok([String("hello")])) 110 - } 111 - 112 - pub fn tokenize_string_with_spaces_test() { 113 - lexer.tokenize("\"hello world\"") 114 - |> should.equal(Ok([String("hello world")])) 115 - } 116 - 117 - pub fn tokenize_string_with_escape_test() { 118 - lexer.tokenize("\"hello\\nworld\"") 119 - |> should.equal(Ok([String("hello\nworld")])) 120 - } 121 - 122 - // Whitespace handling (should be filtered out by default) 123 - pub fn tokenize_with_spaces_test() { 124 - lexer.tokenize("query user") 125 - |> should.equal(Ok([Name("query"), Name("user")])) 126 - } 127 - 128 - pub fn tokenize_with_tabs_test() { 129 - lexer.tokenize("query\tuser") 130 - |> should.equal(Ok([Name("query"), Name("user")])) 131 - } 132 - 133 - pub fn tokenize_with_newlines_test() { 134 - lexer.tokenize("query\nuser") 135 - |> should.equal(Ok([Name("query"), Name("user")])) 136 - } 137 - 138 - // Comment tests (should be filtered out) 139 - pub fn tokenize_with_comment_test() { 140 - lexer.tokenize("query # this is a comment\nuser") 141 - |> should.equal(Ok([Name("query"), Name("user")])) 142 - } 143 - 144 - // Complex query tests 145 - pub fn tokenize_simple_query_test() { 146 - lexer.tokenize("{ user }") 147 - |> should.equal(Ok([BraceOpen, Name("user"), BraceClose])) 148 - } 149 - 150 - pub fn tokenize_query_with_field_test() { 151 - lexer.tokenize("{ user { name } }") 152 - |> should.equal( 153 - Ok([ 154 - BraceOpen, 155 - Name("user"), 156 - BraceOpen, 157 - Name("name"), 158 - BraceClose, 159 - BraceClose, 160 - ]), 161 - ) 162 - } 163 - 164 - pub fn tokenize_query_with_argument_test() { 165 - lexer.tokenize("{ user(id: 42) }") 166 - |> should.equal( 167 - Ok([ 168 - BraceOpen, 169 - Name("user"), 170 - ParenOpen, 171 - Name("id"), 172 - Colon, 173 - Int("42"), 174 - ParenClose, 175 - BraceClose, 176 - ]), 177 - ) 178 - } 179 - 180 - pub fn tokenize_query_with_string_argument_test() { 181 - lexer.tokenize("{ user(name: \"Alice\") }") 182 - |> should.equal( 183 - Ok([ 184 - BraceOpen, 185 - Name("user"), 186 - ParenOpen, 187 - Name("name"), 188 - Colon, 189 - String("Alice"), 190 - ParenClose, 191 - BraceClose, 192 - ]), 193 - ) 194 - } 195 - 196 - // Variable definition tests 197 - pub fn tokenize_variable_definition_test() { 198 - lexer.tokenize("$name: String!") 199 - |> should.equal( 200 - Ok([Dollar, Name("name"), Colon, Name("String"), Exclamation]), 201 - ) 202 - } 203 - 204 - pub fn tokenize_variable_in_query_test() { 205 - lexer.tokenize("query Test($id: Int!) { user }") 206 - |> should.equal( 207 - Ok([ 208 - Name("query"), 209 - Name("Test"), 210 - ParenOpen, 211 - Dollar, 212 - Name("id"), 213 - Colon, 214 - Name("Int"), 215 - Exclamation, 216 - ParenClose, 217 - BraceOpen, 218 - Name("user"), 219 - BraceClose, 220 - ]), 221 - ) 222 - } 223 - 224 - // Error cases - use a truly invalid character like backslash 225 - pub fn tokenize_invalid_character_test() { 226 - lexer.tokenize("query \\invalid") 227 - |> should.be_error() 228 - } 229 - 230 - pub fn tokenize_unclosed_string_test() { 231 - lexer.tokenize("\"unclosed") 232 - |> should.be_error() 233 - }
···
-171
graphql/test/mutation_execution_test.gleam
··· 1 - /// Tests for mutation execution 2 - import birdie 3 - import gleam/list 4 - import gleam/option.{None, Some} 5 - import gleam/string 6 - import gleeunit 7 - import gleeunit/should 8 - import graphql/executor 9 - import graphql/schema 10 - import graphql/value 11 - 12 - pub fn main() { 13 - gleeunit.main() 14 - } 15 - 16 - fn format_response(response: executor.Response) -> String { 17 - string.inspect(response) 18 - } 19 - 20 - fn test_schema_with_mutations() -> schema.Schema { 21 - let user_type = 22 - schema.object_type("User", "A user", [ 23 - schema.field("id", schema.id_type(), "User ID", fn(ctx) { 24 - case ctx.data { 25 - Some(value.Object(fields)) -> { 26 - case fields |> list.key_find("id") { 27 - Ok(id) -> Ok(id) 28 - Error(_) -> Ok(value.String("123")) 29 - } 30 - } 31 - _ -> Ok(value.String("123")) 32 - } 33 - }), 34 - schema.field("name", schema.string_type(), "User name", fn(ctx) { 35 - case ctx.data { 36 - Some(value.Object(fields)) -> { 37 - case fields |> list.key_find("name") { 38 - Ok(name) -> Ok(name) 39 - Error(_) -> Ok(value.String("Unknown")) 40 - } 41 - } 42 - _ -> Ok(value.String("Unknown")) 43 - } 44 - }), 45 - ]) 46 - 47 - let query_type = 48 - schema.object_type("Query", "Root query", [ 49 - schema.field("dummy", schema.string_type(), "Dummy field", fn(_) { 50 - Ok(value.String("dummy")) 51 - }), 52 - ]) 53 - 54 - let mutation_type = 55 - schema.object_type("Mutation", "Root mutation", [ 56 - schema.field_with_args( 57 - "createUser", 58 - user_type, 59 - "Create a user", 60 - [schema.argument("name", schema.string_type(), "User name", None)], 61 - fn(ctx) { 62 - case schema.get_argument(ctx, "name") { 63 - Some(value.String(name)) -> 64 - Ok( 65 - value.Object([ 66 - #("id", value.String("123")), 67 - #("name", value.String(name)), 68 - ]), 69 - ) 70 - _ -> 71 - Ok( 72 - value.Object([ 73 - #("id", value.String("123")), 74 - #("name", value.String("Default Name")), 75 - ]), 76 - ) 77 - } 78 - }, 79 - ), 80 - schema.field_with_args( 81 - "deleteUser", 82 - schema.boolean_type(), 83 - "Delete a user", 84 - [ 85 - schema.argument( 86 - "id", 87 - schema.non_null(schema.id_type()), 88 - "User ID", 89 - None, 90 - ), 91 - ], 92 - fn(_) { Ok(value.Boolean(True)) }, 93 - ), 94 - ]) 95 - 96 - schema.schema(query_type, Some(mutation_type)) 97 - } 98 - 99 - pub fn execute_simple_mutation_test() { 100 - let schema = test_schema_with_mutations() 101 - let query = "mutation { createUser(name: \"Alice\") { id name } }" 102 - 103 - let result = executor.execute(query, schema, schema.context(None)) 104 - 105 - let response = case result { 106 - Ok(r) -> r 107 - Error(_) -> panic as "Execution failed" 108 - } 109 - 110 - birdie.snap( 111 - title: "Execute simple mutation", 112 - content: format_response(response), 113 - ) 114 - } 115 - 116 - pub fn execute_named_mutation_test() { 117 - let schema = test_schema_with_mutations() 118 - let query = "mutation CreateUser { createUser(name: \"Bob\") { id name } }" 119 - 120 - let result = executor.execute(query, schema, schema.context(None)) 121 - 122 - should.be_ok(result) 123 - } 124 - 125 - pub fn execute_multiple_mutations_test() { 126 - let schema = test_schema_with_mutations() 127 - let query = 128 - " 129 - mutation { 130 - createUser(name: \"Alice\") { id name } 131 - deleteUser(id: \"123\") 132 - } 133 - " 134 - 135 - let result = executor.execute(query, schema, schema.context(None)) 136 - 137 - let response = case result { 138 - Ok(r) -> r 139 - Error(_) -> panic as "Execution failed" 140 - } 141 - 142 - birdie.snap( 143 - title: "Execute multiple mutations", 144 - content: format_response(response), 145 - ) 146 - } 147 - 148 - pub fn execute_mutation_without_argument_test() { 149 - let schema = test_schema_with_mutations() 150 - let query = "mutation { createUser { id name } }" 151 - 152 - let result = executor.execute(query, schema, schema.context(None)) 153 - 154 - should.be_ok(result) 155 - } 156 - 157 - pub fn execute_mutation_with_context_test() { 158 - let schema = test_schema_with_mutations() 159 - let query = "mutation { createUser(name: \"Context User\") { id name } }" 160 - 161 - let ctx_data = 162 - value.Object([ 163 - #("userId", value.String("456")), 164 - #("token", value.String("abc123")), 165 - ]) 166 - let ctx = schema.context(Some(ctx_data)) 167 - 168 - let result = executor.execute(query, schema, ctx) 169 - 170 - should.be_ok(result) 171 - }
···
-105
graphql/test/mutation_parser_test.gleam
··· 1 - /// Snapshot tests for mutation parsing 2 - import birdie 3 - import gleam/string 4 - import gleeunit 5 - import graphql/parser 6 - 7 - pub fn main() { 8 - gleeunit.main() 9 - } 10 - 11 - // Helper to format AST as string for snapshots 12 - fn format_ast(doc: parser.Document) -> String { 13 - string.inspect(doc) 14 - } 15 - 16 - pub fn parse_simple_anonymous_mutation_test() { 17 - let query = "mutation { createUser(name: \"Alice\") { id name } }" 18 - 19 - let doc = case parser.parse(query) { 20 - Ok(d) -> d 21 - Error(_) -> panic as "Parse failed" 22 - } 23 - 24 - birdie.snap(title: "Simple anonymous mutation", content: format_ast(doc)) 25 - } 26 - 27 - pub fn parse_named_mutation_test() { 28 - let query = "mutation CreateUser { createUser(name: \"Alice\") { id name } }" 29 - 30 - let doc = case parser.parse(query) { 31 - Ok(d) -> d 32 - Error(_) -> panic as "Parse failed" 33 - } 34 - 35 - birdie.snap(title: "Named mutation", content: format_ast(doc)) 36 - } 37 - 38 - pub fn parse_mutation_with_input_object_test() { 39 - let query = 40 - " 41 - mutation { 42 - createUser(input: { name: \"Alice\", email: \"alice@example.com\", age: 30 }) { 43 - id 44 - name 45 - email 46 - } 47 - } 48 - " 49 - 50 - let doc = case parser.parse(query) { 51 - Ok(d) -> d 52 - Error(_) -> panic as "Parse failed" 53 - } 54 - 55 - birdie.snap( 56 - title: "Parse mutation with input object argument", 57 - content: format_ast(doc), 58 - ) 59 - } 60 - 61 - pub fn parse_multiple_mutations_test() { 62 - let query = 63 - " 64 - mutation { 65 - createUser(name: \"Alice\") { id } 66 - deleteUser(id: \"123\") { success } 67 - } 68 - " 69 - 70 - let doc = case parser.parse(query) { 71 - Ok(d) -> d 72 - Error(_) -> panic as "Parse failed" 73 - } 74 - 75 - birdie.snap( 76 - title: "Multiple mutations in one operation", 77 - content: format_ast(doc), 78 - ) 79 - } 80 - 81 - pub fn parse_mutation_with_nested_selections_test() { 82 - let query = 83 - " 84 - mutation { 85 - createPost(input: { title: \"Hello\" }) { 86 - id 87 - author { 88 - id 89 - name 90 - } 91 - tags 92 - } 93 - } 94 - " 95 - 96 - let doc = case parser.parse(query) { 97 - Ok(d) -> d 98 - Error(_) -> panic as "Parse failed" 99 - } 100 - 101 - birdie.snap( 102 - title: "Mutation with nested selections", 103 - content: format_ast(doc), 104 - ) 105 - }
···
-214
graphql/test/mutation_sdl_test.gleam
··· 1 - /// Snapshot tests for mutation SDL generation 2 - import birdie 3 - import gleam/option.{None} 4 - import gleeunit 5 - import graphql/schema 6 - import graphql/sdl 7 - import graphql/value 8 - 9 - pub fn main() { 10 - gleeunit.main() 11 - } 12 - 13 - pub fn simple_mutation_type_test() { 14 - let user_type = 15 - schema.object_type("User", "A user", [ 16 - schema.field("id", schema.non_null(schema.id_type()), "User ID", fn(_) { 17 - Ok(value.String("1")) 18 - }), 19 - schema.field("name", schema.string_type(), "User name", fn(_) { 20 - Ok(value.String("Alice")) 21 - }), 22 - ]) 23 - 24 - let mutation_type = 25 - schema.object_type("Mutation", "Root mutation type", [ 26 - schema.field_with_args( 27 - "createUser", 28 - user_type, 29 - "Create a new user", 30 - [ 31 - schema.argument( 32 - "name", 33 - schema.non_null(schema.string_type()), 34 - "User name", 35 - None, 36 - ), 37 - ], 38 - fn(_) { Ok(value.Null) }, 39 - ), 40 - ]) 41 - 42 - let serialized = sdl.print_type(mutation_type) 43 - 44 - birdie.snap(title: "Simple mutation type", content: serialized) 45 - } 46 - 47 - pub fn mutation_with_input_object_test() { 48 - let create_user_input = 49 - schema.input_object_type("CreateUserInput", "Input for creating a user", [ 50 - schema.input_field( 51 - "name", 52 - schema.non_null(schema.string_type()), 53 - "User name", 54 - None, 55 - ), 56 - schema.input_field( 57 - "email", 58 - schema.non_null(schema.string_type()), 59 - "Email address", 60 - None, 61 - ), 62 - schema.input_field("age", schema.int_type(), "Age", None), 63 - ]) 64 - 65 - let user_type = 66 - schema.object_type("User", "A user", [ 67 - schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }), 68 - schema.field("name", schema.string_type(), "User name", fn(_) { 69 - Ok(value.Null) 70 - }), 71 - ]) 72 - 73 - let mutation_type = 74 - schema.object_type("Mutation", "Mutations", [ 75 - schema.field_with_args( 76 - "createUser", 77 - user_type, 78 - "Create a new user", 79 - [ 80 - schema.argument( 81 - "input", 82 - schema.non_null(create_user_input), 83 - "User data", 84 - None, 85 - ), 86 - ], 87 - fn(_) { Ok(value.Null) }, 88 - ), 89 - ]) 90 - 91 - let serialized = 92 - sdl.print_types([create_user_input, user_type, mutation_type]) 93 - 94 - birdie.snap(title: "Mutation with input object argument", content: serialized) 95 - } 96 - 97 - pub fn multiple_mutations_test() { 98 - let user_type = 99 - schema.object_type("User", "A user", [ 100 - schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }), 101 - ]) 102 - 103 - let delete_response = 104 - schema.object_type("DeleteResponse", "Delete response", [ 105 - schema.field("success", schema.boolean_type(), "Success flag", fn(_) { 106 - Ok(value.Null) 107 - }), 108 - ]) 109 - 110 - let mutation_type = 111 - schema.object_type("Mutation", "Mutations", [ 112 - schema.field_with_args( 113 - "createUser", 114 - user_type, 115 - "Create a user", 116 - [schema.argument("name", schema.string_type(), "Name", None)], 117 - fn(_) { Ok(value.Null) }, 118 - ), 119 - schema.field_with_args( 120 - "updateUser", 121 - user_type, 122 - "Update a user", 123 - [ 124 - schema.argument( 125 - "id", 126 - schema.non_null(schema.id_type()), 127 - "User ID", 128 - None, 129 - ), 130 - schema.argument("name", schema.string_type(), "New name", None), 131 - ], 132 - fn(_) { Ok(value.Null) }, 133 - ), 134 - schema.field_with_args( 135 - "deleteUser", 136 - delete_response, 137 - "Delete a user", 138 - [ 139 - schema.argument( 140 - "id", 141 - schema.non_null(schema.id_type()), 142 - "User ID", 143 - None, 144 - ), 145 - ], 146 - fn(_) { Ok(value.Null) }, 147 - ), 148 - ]) 149 - 150 - let serialized = sdl.print_type(mutation_type) 151 - 152 - birdie.snap( 153 - title: "Multiple mutations (CRUD operations)", 154 - content: serialized, 155 - ) 156 - } 157 - 158 - pub fn mutation_returning_list_test() { 159 - let user_type = 160 - schema.object_type("User", "A user", [ 161 - schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }), 162 - ]) 163 - 164 - let mutation_type = 165 - schema.object_type("Mutation", "Mutations", [ 166 - schema.field_with_args( 167 - "createUsers", 168 - schema.list_type(user_type), 169 - "Create multiple users", 170 - [ 171 - schema.argument( 172 - "names", 173 - schema.list_type(schema.non_null(schema.string_type())), 174 - "User names", 175 - None, 176 - ), 177 - ], 178 - fn(_) { Ok(value.Null) }, 179 - ), 180 - ]) 181 - 182 - let serialized = sdl.print_type(mutation_type) 183 - 184 - birdie.snap(title: "Mutation returning list", content: serialized) 185 - } 186 - 187 - pub fn mutation_with_non_null_return_test() { 188 - let user_type = 189 - schema.object_type("User", "A user", [ 190 - schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }), 191 - ]) 192 - 193 - let mutation_type = 194 - schema.object_type("Mutation", "Mutations", [ 195 - schema.field_with_args( 196 - "createUser", 197 - schema.non_null(user_type), 198 - "Create a user (guaranteed to return)", 199 - [ 200 - schema.argument( 201 - "name", 202 - schema.non_null(schema.string_type()), 203 - "User name", 204 - None, 205 - ), 206 - ], 207 - fn(_) { Ok(value.Null) }, 208 - ), 209 - ]) 210 - 211 - let serialized = sdl.print_type(mutation_type) 212 - 213 - birdie.snap(title: "Mutation with non-null return type", content: serialized) 214 - }
···
-640
graphql/test/parser_test.gleam
··· 1 - /// Tests for GraphQL Parser (AST building) 2 - /// 3 - /// GraphQL spec Section 2 - Language 4 - /// Parse tokens into Abstract Syntax Tree 5 - import gleam/list 6 - import gleam/option.{None} 7 - import gleeunit/should 8 - import graphql/parser 9 - 10 - // Simple query tests 11 - pub fn parse_empty_query_test() { 12 - "{ }" 13 - |> parser.parse 14 - |> should.be_ok 15 - } 16 - 17 - pub fn parse_anonymous_query_with_keyword_test() { 18 - "query { user }" 19 - |> parser.parse 20 - |> should.be_ok 21 - |> fn(doc) { 22 - case doc { 23 - parser.Document([ 24 - parser.Query(parser.SelectionSet([parser.Field("user", None, [], [])])), 25 - ]) -> True 26 - _ -> False 27 - } 28 - } 29 - |> should.be_true 30 - } 31 - 32 - pub fn parse_single_field_test() { 33 - "{ user }" 34 - |> parser.parse 35 - |> should.be_ok 36 - |> fn(doc) { 37 - case doc { 38 - parser.Document([ 39 - parser.Query(parser.SelectionSet([ 40 - parser.Field(name: "user", alias: None, arguments: [], selections: []), 41 - ])), 42 - ]) -> True 43 - _ -> False 44 - } 45 - } 46 - |> should.be_true 47 - } 48 - 49 - pub fn parse_nested_fields_test() { 50 - "{ user { name } }" 51 - |> parser.parse 52 - |> should.be_ok 53 - |> fn(doc) { 54 - case doc { 55 - parser.Document([ 56 - parser.Query(parser.SelectionSet([ 57 - parser.Field( 58 - name: "user", 59 - alias: None, 60 - arguments: [], 61 - selections: [parser.Field("name", None, [], [])], 62 - ), 63 - ])), 64 - ]) -> True 65 - _ -> False 66 - } 67 - } 68 - |> should.be_true 69 - } 70 - 71 - pub fn parse_multiple_fields_test() { 72 - "{ user posts }" 73 - |> parser.parse 74 - |> should.be_ok 75 - |> fn(doc) { 76 - case doc { 77 - parser.Document([ 78 - parser.Query(parser.SelectionSet([ 79 - parser.Field(name: "user", alias: None, arguments: [], selections: []), 80 - parser.Field( 81 - name: "posts", 82 - alias: None, 83 - arguments: [], 84 - selections: [], 85 - ), 86 - ])), 87 - ]) -> True 88 - _ -> False 89 - } 90 - } 91 - |> should.be_true 92 - } 93 - 94 - // Arguments tests 95 - pub fn parse_field_with_int_argument_test() { 96 - "{ user(id: 42) }" 97 - |> parser.parse 98 - |> should.be_ok 99 - |> fn(doc) { 100 - case doc { 101 - parser.Document([ 102 - parser.Query(parser.SelectionSet([ 103 - parser.Field( 104 - name: "user", 105 - alias: None, 106 - arguments: [parser.Argument("id", parser.IntValue("42"))], 107 - selections: [], 108 - ), 109 - ])), 110 - ]) -> True 111 - _ -> False 112 - } 113 - } 114 - |> should.be_true 115 - } 116 - 117 - pub fn parse_field_with_string_argument_test() { 118 - "{ user(name: \"Alice\") }" 119 - |> parser.parse 120 - |> should.be_ok 121 - |> fn(doc) { 122 - case doc { 123 - parser.Document([ 124 - parser.Query(parser.SelectionSet([ 125 - parser.Field( 126 - name: "user", 127 - alias: None, 128 - arguments: [parser.Argument("name", parser.StringValue("Alice"))], 129 - selections: [], 130 - ), 131 - ])), 132 - ]) -> True 133 - _ -> False 134 - } 135 - } 136 - |> should.be_true 137 - } 138 - 139 - pub fn parse_field_with_multiple_arguments_test() { 140 - "{ user(id: 42, name: \"Alice\") }" 141 - |> parser.parse 142 - |> should.be_ok 143 - |> fn(doc) { 144 - case doc { 145 - parser.Document([ 146 - parser.Query(parser.SelectionSet([ 147 - parser.Field( 148 - name: "user", 149 - alias: None, 150 - arguments: [ 151 - parser.Argument("id", parser.IntValue("42")), 152 - parser.Argument("name", parser.StringValue("Alice")), 153 - ], 154 - selections: [], 155 - ), 156 - ])), 157 - ]) -> True 158 - _ -> False 159 - } 160 - } 161 - |> should.be_true 162 - } 163 - 164 - // Named operation tests 165 - pub fn parse_named_query_test() { 166 - "query GetUser { user }" 167 - |> parser.parse 168 - |> should.be_ok 169 - |> fn(doc) { 170 - case doc { 171 - parser.Document([ 172 - parser.NamedQuery( 173 - name: "GetUser", 174 - variables: [], 175 - selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 176 - ), 177 - ]) -> True 178 - _ -> False 179 - } 180 - } 181 - |> should.be_true 182 - } 183 - 184 - // Complex query test 185 - pub fn parse_complex_query_test() { 186 - " 187 - query GetUserPosts { 188 - user(id: 1) { 189 - name 190 - posts { 191 - title 192 - content 193 - } 194 - } 195 - } 196 - " 197 - |> parser.parse 198 - |> should.be_ok 199 - } 200 - 201 - // Error cases 202 - pub fn parse_invalid_syntax_test() { 203 - "{ user" 204 - |> parser.parse 205 - |> should.be_error 206 - } 207 - 208 - pub fn parse_empty_string_test() { 209 - "" 210 - |> parser.parse 211 - |> should.be_error 212 - } 213 - 214 - pub fn parse_invalid_field_name_test() { 215 - "{ 123 }" 216 - |> parser.parse 217 - |> should.be_error 218 - } 219 - 220 - // Fragment tests 221 - pub fn parse_fragment_definition_test() { 222 - " 223 - fragment UserFields on User { 224 - id 225 - name 226 - } 227 - { user { ...UserFields } } 228 - " 229 - |> parser.parse 230 - |> should.be_ok 231 - |> fn(doc) { 232 - case doc { 233 - parser.Document([ 234 - parser.FragmentDefinition( 235 - name: "UserFields", 236 - type_condition: "User", 237 - selections: parser.SelectionSet([ 238 - parser.Field("id", None, [], []), 239 - parser.Field("name", None, [], []), 240 - ]), 241 - ), 242 - parser.Query(parser.SelectionSet([ 243 - parser.Field( 244 - name: "user", 245 - alias: None, 246 - arguments: [], 247 - selections: [parser.FragmentSpread("UserFields")], 248 - ), 249 - ])), 250 - ]) -> True 251 - _ -> False 252 - } 253 - } 254 - |> should.be_true 255 - } 256 - 257 - pub fn parse_fragment_single_line_test() { 258 - // The multiline version works - let's try it 259 - " 260 - { __type(name: \"Query\") { ...TypeFrag } } 261 - fragment TypeFrag on __Type { name kind } 262 - " 263 - |> parser.parse 264 - |> should.be_ok 265 - |> fn(doc) { 266 - case doc { 267 - parser.Document(operations) -> list.length(operations) == 2 268 - } 269 - } 270 - |> should.be_true 271 - } 272 - 273 - pub fn parse_fragment_truly_single_line_test() { 274 - // This is the problematic single-line version 275 - "{ __type(name: \"Query\") { ...TypeFrag } } fragment TypeFrag on __Type { name kind }" 276 - |> parser.parse 277 - |> should.be_ok 278 - |> fn(doc) { 279 - case doc { 280 - parser.Document(operations) -> { 281 - // If we only got 1 operation, the parser stopped after the query 282 - case operations { 283 - [parser.Query(_)] -> 284 - panic as "Only got Query - fragment was not parsed" 285 - _ -> list.length(operations) == 2 286 - } 287 - } 288 - } 289 - } 290 - |> should.be_true 291 - } 292 - 293 - pub fn parse_inline_fragment_test() { 294 - " 295 - { user { ... on User { name } } } 296 - " 297 - |> parser.parse 298 - |> should.be_ok 299 - } 300 - 301 - // List value tests 302 - pub fn parse_empty_list_argument_test() { 303 - "{ user(tags: []) }" 304 - |> parser.parse 305 - |> should.be_ok 306 - |> fn(doc) { 307 - case doc { 308 - parser.Document([ 309 - parser.Query(parser.SelectionSet([ 310 - parser.Field( 311 - name: "user", 312 - alias: None, 313 - arguments: [parser.Argument("tags", parser.ListValue([]))], 314 - selections: [], 315 - ), 316 - ])), 317 - ]) -> True 318 - _ -> False 319 - } 320 - } 321 - |> should.be_true 322 - } 323 - 324 - pub fn parse_list_of_ints_test() { 325 - "{ user(ids: [1, 2, 3]) }" 326 - |> parser.parse 327 - |> should.be_ok 328 - |> fn(doc) { 329 - case doc { 330 - parser.Document([ 331 - parser.Query(parser.SelectionSet([ 332 - parser.Field( 333 - name: "user", 334 - alias: None, 335 - arguments: [ 336 - parser.Argument( 337 - "ids", 338 - parser.ListValue([ 339 - parser.IntValue("1"), 340 - parser.IntValue("2"), 341 - parser.IntValue("3"), 342 - ]), 343 - ), 344 - ], 345 - selections: [], 346 - ), 347 - ])), 348 - ]) -> True 349 - _ -> False 350 - } 351 - } 352 - |> should.be_true 353 - } 354 - 355 - pub fn parse_list_of_strings_test() { 356 - "{ user(tags: [\"foo\", \"bar\"]) }" 357 - |> parser.parse 358 - |> should.be_ok 359 - |> fn(doc) { 360 - case doc { 361 - parser.Document([ 362 - parser.Query(parser.SelectionSet([ 363 - parser.Field( 364 - name: "user", 365 - alias: None, 366 - arguments: [ 367 - parser.Argument( 368 - "tags", 369 - parser.ListValue([ 370 - parser.StringValue("foo"), 371 - parser.StringValue("bar"), 372 - ]), 373 - ), 374 - ], 375 - selections: [], 376 - ), 377 - ])), 378 - ]) -> True 379 - _ -> False 380 - } 381 - } 382 - |> should.be_true 383 - } 384 - 385 - // Object value tests 386 - pub fn parse_empty_object_argument_test() { 387 - "{ user(filter: {}) }" 388 - |> parser.parse 389 - |> should.be_ok 390 - |> fn(doc) { 391 - case doc { 392 - parser.Document([ 393 - parser.Query(parser.SelectionSet([ 394 - parser.Field( 395 - name: "user", 396 - alias: None, 397 - arguments: [parser.Argument("filter", parser.ObjectValue([]))], 398 - selections: [], 399 - ), 400 - ])), 401 - ]) -> True 402 - _ -> False 403 - } 404 - } 405 - |> should.be_true 406 - } 407 - 408 - pub fn parse_object_with_fields_test() { 409 - "{ user(filter: {name: \"Alice\", age: 30}) }" 410 - |> parser.parse 411 - |> should.be_ok 412 - |> fn(doc) { 413 - case doc { 414 - parser.Document([ 415 - parser.Query(parser.SelectionSet([ 416 - parser.Field( 417 - name: "user", 418 - alias: None, 419 - arguments: [ 420 - parser.Argument( 421 - "filter", 422 - parser.ObjectValue([ 423 - #("name", parser.StringValue("Alice")), 424 - #("age", parser.IntValue("30")), 425 - ]), 426 - ), 427 - ], 428 - selections: [], 429 - ), 430 - ])), 431 - ]) -> True 432 - _ -> False 433 - } 434 - } 435 - |> should.be_true 436 - } 437 - 438 - // Nested structures 439 - pub fn parse_list_of_objects_test() { 440 - "{ posts(sortBy: [{field: \"date\", direction: DESC}]) }" 441 - |> parser.parse 442 - |> should.be_ok 443 - |> fn(doc) { 444 - case doc { 445 - parser.Document([ 446 - parser.Query(parser.SelectionSet([ 447 - parser.Field( 448 - name: "posts", 449 - alias: None, 450 - arguments: [ 451 - parser.Argument( 452 - "sortBy", 453 - parser.ListValue([ 454 - parser.ObjectValue([ 455 - #("field", parser.StringValue("date")), 456 - #("direction", parser.EnumValue("DESC")), 457 - ]), 458 - ]), 459 - ), 460 - ], 461 - selections: [], 462 - ), 463 - ])), 464 - ]) -> True 465 - _ -> False 466 - } 467 - } 468 - |> should.be_true 469 - } 470 - 471 - pub fn parse_object_with_nested_list_test() { 472 - "{ user(filter: {tags: [\"a\", \"b\"]}) }" 473 - |> parser.parse 474 - |> should.be_ok 475 - } 476 - 477 - // Variable definition tests 478 - pub fn parse_query_with_one_variable_test() { 479 - "query Test($name: String!) { user }" 480 - |> parser.parse 481 - |> should.be_ok 482 - |> fn(doc) { 483 - case doc { 484 - parser.Document([ 485 - parser.NamedQuery( 486 - name: "Test", 487 - variables: [parser.Variable("name", "String!")], 488 - selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 489 - ), 490 - ]) -> True 491 - _ -> False 492 - } 493 - } 494 - |> should.be_true 495 - } 496 - 497 - pub fn parse_query_with_multiple_variables_test() { 498 - "query Test($name: String!, $age: Int) { user }" 499 - |> parser.parse 500 - |> should.be_ok 501 - |> fn(doc) { 502 - case doc { 503 - parser.Document([ 504 - parser.NamedQuery( 505 - name: "Test", 506 - variables: [ 507 - parser.Variable("name", "String!"), 508 - parser.Variable("age", "Int"), 509 - ], 510 - selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 511 - ), 512 - ]) -> True 513 - _ -> False 514 - } 515 - } 516 - |> should.be_true 517 - } 518 - 519 - pub fn parse_mutation_with_variables_test() { 520 - "mutation CreateUser($name: String!, $email: String!) { createUser }" 521 - |> parser.parse 522 - |> should.be_ok 523 - |> fn(doc) { 524 - case doc { 525 - parser.Document([ 526 - parser.NamedMutation( 527 - name: "CreateUser", 528 - variables: [ 529 - parser.Variable("name", "String!"), 530 - parser.Variable("email", "String!"), 531 - ], 532 - selections: parser.SelectionSet([ 533 - parser.Field("createUser", None, [], []), 534 - ]), 535 - ), 536 - ]) -> True 537 - _ -> False 538 - } 539 - } 540 - |> should.be_true 541 - } 542 - 543 - pub fn parse_variable_value_in_argument_test() { 544 - "{ user(name: $userName) }" 545 - |> parser.parse 546 - |> should.be_ok 547 - |> fn(doc) { 548 - case doc { 549 - parser.Document([ 550 - parser.Query(parser.SelectionSet([ 551 - parser.Field( 552 - name: "user", 553 - alias: None, 554 - arguments: [ 555 - parser.Argument("name", parser.VariableValue("userName")), 556 - ], 557 - selections: [], 558 - ), 559 - ])), 560 - ]) -> True 561 - _ -> False 562 - } 563 - } 564 - |> should.be_true 565 - } 566 - 567 - // Subscription tests 568 - pub fn parse_anonymous_subscription_with_keyword_test() { 569 - "subscription { messageAdded }" 570 - |> parser.parse 571 - |> should.be_ok 572 - |> fn(doc) { 573 - case doc { 574 - parser.Document([ 575 - parser.Subscription(parser.SelectionSet([ 576 - parser.Field("messageAdded", None, [], []), 577 - ])), 578 - ]) -> True 579 - _ -> False 580 - } 581 - } 582 - |> should.be_true 583 - } 584 - 585 - pub fn parse_named_subscription_test() { 586 - "subscription OnMessage { messageAdded { content } }" 587 - |> parser.parse 588 - |> should.be_ok 589 - |> fn(doc) { 590 - case doc { 591 - parser.Document([ 592 - parser.NamedSubscription( 593 - "OnMessage", 594 - [], 595 - parser.SelectionSet([ 596 - parser.Field( 597 - name: "messageAdded", 598 - alias: None, 599 - arguments: [], 600 - selections: [parser.Field("content", None, [], [])], 601 - ), 602 - ]), 603 - ), 604 - ]) -> True 605 - _ -> False 606 - } 607 - } 608 - |> should.be_true 609 - } 610 - 611 - pub fn parse_subscription_with_nested_fields_test() { 612 - "subscription { postCreated { id title author { name } } }" 613 - |> parser.parse 614 - |> should.be_ok 615 - |> fn(doc) { 616 - case doc { 617 - parser.Document([ 618 - parser.Subscription(parser.SelectionSet([ 619 - parser.Field( 620 - name: "postCreated", 621 - alias: None, 622 - arguments: [], 623 - selections: [ 624 - parser.Field("id", None, [], []), 625 - parser.Field("title", None, [], []), 626 - parser.Field( 627 - name: "author", 628 - alias: None, 629 - arguments: [], 630 - selections: [parser.Field("name", None, [], [])], 631 - ), 632 - ], 633 - ), 634 - ])), 635 - ]) -> True 636 - _ -> False 637 - } 638 - } 639 - |> should.be_true 640 - }
···
-222
graphql/test/schema_test.gleam
··· 1 - /// Tests for GraphQL Schema (Type System) 2 - /// 3 - /// GraphQL spec Section 3 - Type System 4 - /// Defines types, fields, and schema structure 5 - import gleam/option.{None} 6 - import gleeunit/should 7 - import graphql/schema 8 - import graphql/value 9 - 10 - // Type system tests 11 - pub fn create_scalar_type_test() { 12 - let string_type = schema.string_type() 13 - should.equal(schema.type_name(string_type), "String") 14 - } 15 - 16 - pub fn create_object_type_test() { 17 - let user_type = 18 - schema.object_type("User", "A user in the system", [ 19 - schema.field("id", schema.id_type(), "User ID", fn(_ctx) { 20 - Ok(value.String("123")) 21 - }), 22 - schema.field("name", schema.string_type(), "User name", fn(_ctx) { 23 - Ok(value.String("Alice")) 24 - }), 25 - ]) 26 - 27 - should.equal(schema.type_name(user_type), "User") 28 - } 29 - 30 - pub fn create_non_null_type_test() { 31 - let non_null_string = schema.non_null(schema.string_type()) 32 - should.be_true(schema.is_non_null(non_null_string)) 33 - } 34 - 35 - pub fn create_list_type_test() { 36 - let list_of_strings = schema.list_type(schema.string_type()) 37 - should.be_true(schema.is_list(list_of_strings)) 38 - } 39 - 40 - pub fn create_schema_test() { 41 - let query_type = 42 - schema.object_type("Query", "Root query type", [ 43 - schema.field("hello", schema.string_type(), "Hello field", fn(_ctx) { 44 - Ok(value.String("world")) 45 - }), 46 - ]) 47 - 48 - let graphql_schema = schema.schema(query_type, None) 49 - should.equal(schema.query_type(graphql_schema), query_type) 50 - } 51 - 52 - pub fn field_with_arguments_test() { 53 - let user_field = 54 - schema.field_with_args( 55 - "user", 56 - schema.string_type(), 57 - "Get user by ID", 58 - [schema.argument("id", schema.id_type(), "User ID", None)], 59 - fn(_ctx) { Ok(value.String("Alice")) }, 60 - ) 61 - 62 - should.equal(schema.field_name(user_field), "user") 63 - } 64 - 65 - pub fn enum_type_test() { 66 - let role_enum = 67 - schema.enum_type("Role", "User role", [ 68 - schema.enum_value("ADMIN", "Administrator"), 69 - schema.enum_value("USER", "Regular user"), 70 - ]) 71 - 72 - should.equal(schema.type_name(role_enum), "Role") 73 - } 74 - 75 - pub fn scalar_types_exist_test() { 76 - // Built-in scalar types 77 - let _string = schema.string_type() 78 - let _int = schema.int_type() 79 - let _float = schema.float_type() 80 - let _boolean = schema.boolean_type() 81 - let _id = schema.id_type() 82 - 83 - should.be_true(True) 84 - } 85 - 86 - // Union type tests 87 - pub fn create_union_type_test() { 88 - let post_type = 89 - schema.object_type("Post", "A blog post", [ 90 - schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 91 - Ok(value.String("Hello")) 92 - }), 93 - ]) 94 - 95 - let comment_type = 96 - schema.object_type("Comment", "A comment", [ 97 - schema.field("text", schema.string_type(), "Comment text", fn(_ctx) { 98 - Ok(value.String("Nice post")) 99 - }), 100 - ]) 101 - 102 - let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) { 103 - Ok("Post") 104 - } 105 - 106 - let union_type = 107 - schema.union_type( 108 - "SearchResult", 109 - "A search result", 110 - [post_type, comment_type], 111 - type_resolver, 112 - ) 113 - 114 - should.equal(schema.type_name(union_type), "SearchResult") 115 - should.be_true(schema.is_union(union_type)) 116 - } 117 - 118 - pub fn union_possible_types_test() { 119 - let post_type = 120 - schema.object_type("Post", "A blog post", [ 121 - schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 122 - Ok(value.String("Hello")) 123 - }), 124 - ]) 125 - 126 - let comment_type = 127 - schema.object_type("Comment", "A comment", [ 128 - schema.field("text", schema.string_type(), "Comment text", fn(_ctx) { 129 - Ok(value.String("Nice post")) 130 - }), 131 - ]) 132 - 133 - let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) { 134 - Ok("Post") 135 - } 136 - 137 - let union_type = 138 - schema.union_type( 139 - "SearchResult", 140 - "A search result", 141 - [post_type, comment_type], 142 - type_resolver, 143 - ) 144 - 145 - let possible_types = schema.get_possible_types(union_type) 146 - should.equal(possible_types, [post_type, comment_type]) 147 - } 148 - 149 - pub fn resolve_union_type_test() { 150 - let post_type = 151 - schema.object_type("Post", "A blog post", [ 152 - schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 153 - Ok(value.String("Hello")) 154 - }), 155 - ]) 156 - 157 - let comment_type = 158 - schema.object_type("Comment", "A comment", [ 159 - schema.field("text", schema.string_type(), "Comment text", fn(_ctx) { 160 - Ok(value.String("Nice post")) 161 - }), 162 - ]) 163 - 164 - // Type resolver that examines the __typename field in the data 165 - let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 166 - case ctx.data { 167 - None -> Error("No data") 168 - option.Some(value.Object(fields)) -> { 169 - case fields { 170 - [#("__typename", value.String(type_name)), ..] -> Ok(type_name) 171 - _ -> Error("No __typename field") 172 - } 173 - } 174 - _ -> Error("Data is not an object") 175 - } 176 - } 177 - 178 - let union_type = 179 - schema.union_type( 180 - "SearchResult", 181 - "A search result", 182 - [post_type, comment_type], 183 - type_resolver, 184 - ) 185 - 186 - // Create context with data that has __typename 187 - let data = 188 - value.Object([ 189 - #("__typename", value.String("Post")), 190 - #("title", value.String("Test")), 191 - ]) 192 - let ctx = schema.context(option.Some(data)) 193 - let result = schema.resolve_union_type(union_type, ctx) 194 - 195 - case result { 196 - Ok(resolved_type) -> should.equal(schema.type_name(resolved_type), "Post") 197 - Error(_) -> should.be_true(False) 198 - } 199 - } 200 - 201 - pub fn union_type_kind_test() { 202 - let post_type = 203 - schema.object_type("Post", "A blog post", [ 204 - schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 205 - Ok(value.String("Hello")) 206 - }), 207 - ]) 208 - 209 - let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) { 210 - Ok("Post") 211 - } 212 - 213 - let union_type = 214 - schema.union_type( 215 - "SearchResult", 216 - "A search result", 217 - [post_type], 218 - type_resolver, 219 - ) 220 - 221 - should.equal(schema.type_kind(union_type), "UNION") 222 - }
···
-269
graphql/test/sdl_test.gleam
··· 1 - /// Snapshot tests for SDL generation 2 - /// 3 - /// Verifies that GraphQL types are correctly serialized to SDL format 4 - import birdie 5 - import gleam/option.{None, Some} 6 - import gleeunit 7 - import graphql/schema 8 - import graphql/sdl 9 - import graphql/value 10 - 11 - pub fn main() { 12 - gleeunit.main() 13 - } 14 - 15 - // ===== Input Object Types ===== 16 - 17 - pub fn simple_input_object_test() { 18 - let input_type = 19 - schema.input_object_type( 20 - "UserInput", 21 - "Input for creating or updating a user", 22 - [ 23 - schema.input_field("name", schema.string_type(), "User's name", None), 24 - schema.input_field( 25 - "email", 26 - schema.non_null(schema.string_type()), 27 - "User's email address", 28 - None, 29 - ), 30 - schema.input_field("age", schema.int_type(), "User's age", None), 31 - ], 32 - ) 33 - 34 - let serialized = sdl.print_type(input_type) 35 - 36 - birdie.snap( 37 - title: "Simple input object with descriptions", 38 - content: serialized, 39 - ) 40 - } 41 - 42 - pub fn input_object_with_default_values_test() { 43 - let input_type = 44 - schema.input_object_type("FilterInput", "Filter options for queries", [ 45 - schema.input_field( 46 - "limit", 47 - schema.int_type(), 48 - "Maximum number of results", 49 - Some(value.Int(10)), 50 - ), 51 - schema.input_field( 52 - "offset", 53 - schema.int_type(), 54 - "Number of results to skip", 55 - Some(value.Int(0)), 56 - ), 57 - ]) 58 - 59 - let serialized = sdl.print_type(input_type) 60 - 61 - birdie.snap(title: "Input object with default values", content: serialized) 62 - } 63 - 64 - pub fn nested_input_types_test() { 65 - let address_input = 66 - schema.input_object_type("AddressInput", "Street address information", [ 67 - schema.input_field("street", schema.string_type(), "Street name", None), 68 - schema.input_field("city", schema.string_type(), "City name", None), 69 - ]) 70 - 71 - let user_input = 72 - schema.input_object_type("UserInput", "User information", [ 73 - schema.input_field("name", schema.string_type(), "Full name", None), 74 - schema.input_field("address", address_input, "Home address", None), 75 - ]) 76 - 77 - let serialized = sdl.print_types([address_input, user_input]) 78 - 79 - birdie.snap(title: "Nested input types", content: serialized) 80 - } 81 - 82 - // ===== Object Types ===== 83 - 84 - pub fn simple_object_type_test() { 85 - let user_type = 86 - schema.object_type("User", "A user in the system", [ 87 - schema.field("id", schema.non_null(schema.id_type()), "User ID", fn(_ctx) { 88 - Ok(value.String("1")) 89 - }), 90 - schema.field("name", schema.string_type(), "User's name", fn(_ctx) { 91 - Ok(value.String("Alice")) 92 - }), 93 - schema.field("email", schema.string_type(), "Email address", fn(_ctx) { 94 - Ok(value.String("alice@example.com")) 95 - }), 96 - ]) 97 - 98 - let serialized = sdl.print_type(user_type) 99 - 100 - birdie.snap(title: "Simple object type", content: serialized) 101 - } 102 - 103 - pub fn object_with_list_fields_test() { 104 - let post_type = 105 - schema.object_type("Post", "A blog post", [ 106 - schema.field("id", schema.id_type(), "Post ID", fn(_ctx) { 107 - Ok(value.String("1")) 108 - }), 109 - schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 110 - Ok(value.String("Hello")) 111 - }), 112 - schema.field( 113 - "tags", 114 - schema.list_type(schema.non_null(schema.string_type())), 115 - "Post tags", 116 - fn(_ctx) { Ok(value.List([])) }, 117 - ), 118 - ]) 119 - 120 - let serialized = sdl.print_type(post_type) 121 - 122 - birdie.snap(title: "Object type with list fields", content: serialized) 123 - } 124 - 125 - // ===== Enum Types ===== 126 - 127 - pub fn simple_enum_test() { 128 - let status_enum = 129 - schema.enum_type("Status", "Order status", [ 130 - schema.enum_value("PENDING", "Order is pending"), 131 - schema.enum_value("PROCESSING", "Order is being processed"), 132 - schema.enum_value("SHIPPED", "Order has been shipped"), 133 - schema.enum_value("DELIVERED", "Order has been delivered"), 134 - ]) 135 - 136 - let serialized = sdl.print_type(status_enum) 137 - 138 - birdie.snap(title: "Simple enum type", content: serialized) 139 - } 140 - 141 - pub fn enum_without_descriptions_test() { 142 - let color_enum = 143 - schema.enum_type("Color", "", [ 144 - schema.enum_value("RED", ""), 145 - schema.enum_value("GREEN", ""), 146 - schema.enum_value("BLUE", ""), 147 - ]) 148 - 149 - let serialized = sdl.print_type(color_enum) 150 - 151 - birdie.snap(title: "Enum without descriptions", content: serialized) 152 - } 153 - 154 - // ===== Scalar Types ===== 155 - 156 - pub fn built_in_scalars_test() { 157 - let scalars = [ 158 - schema.string_type(), 159 - schema.int_type(), 160 - schema.float_type(), 161 - schema.boolean_type(), 162 - schema.id_type(), 163 - ] 164 - 165 - let serialized = sdl.print_types(scalars) 166 - 167 - birdie.snap(title: "Built-in scalar types", content: serialized) 168 - } 169 - 170 - // ===== Complex Types ===== 171 - 172 - pub fn type_with_non_null_and_list_test() { 173 - let input_type = 174 - schema.input_object_type("ComplexInput", "Complex type modifiers", [ 175 - schema.input_field( 176 - "required", 177 - schema.non_null(schema.string_type()), 178 - "Required string", 179 - None, 180 - ), 181 - schema.input_field( 182 - "optionalList", 183 - schema.list_type(schema.string_type()), 184 - "Optional list of strings", 185 - None, 186 - ), 187 - schema.input_field( 188 - "requiredList", 189 - schema.non_null(schema.list_type(schema.string_type())), 190 - "Required list of optional strings", 191 - None, 192 - ), 193 - schema.input_field( 194 - "listOfRequired", 195 - schema.list_type(schema.non_null(schema.string_type())), 196 - "Optional list of required strings", 197 - None, 198 - ), 199 - schema.input_field( 200 - "requiredListOfRequired", 201 - schema.non_null(schema.list_type(schema.non_null(schema.string_type()))), 202 - "Required list of required strings", 203 - None, 204 - ), 205 - ]) 206 - 207 - let serialized = sdl.print_type(input_type) 208 - 209 - birdie.snap( 210 - title: "Type with NonNull and List modifiers", 211 - content: serialized, 212 - ) 213 - } 214 - 215 - // ===== Multiple Related Types ===== 216 - 217 - pub fn related_types_test() { 218 - let sort_direction = 219 - schema.enum_type("SortDirection", "Sort direction for queries", [ 220 - schema.enum_value("ASC", "Ascending order"), 221 - schema.enum_value("DESC", "Descending order"), 222 - ]) 223 - 224 - let sort_field_enum = 225 - schema.enum_type("UserSortField", "Fields to sort users by", [ 226 - schema.enum_value("NAME", "Sort by name"), 227 - schema.enum_value("EMAIL", "Sort by email"), 228 - schema.enum_value("CREATED_AT", "Sort by creation date"), 229 - ]) 230 - 231 - let sort_input = 232 - schema.input_object_type("SortInput", "Sort configuration", [ 233 - schema.input_field( 234 - "field", 235 - schema.non_null(sort_field_enum), 236 - "Field to sort by", 237 - None, 238 - ), 239 - schema.input_field( 240 - "direction", 241 - sort_direction, 242 - "Sort direction", 243 - Some(value.String("ASC")), 244 - ), 245 - ]) 246 - 247 - let serialized = 248 - sdl.print_types([sort_direction, sort_field_enum, sort_input]) 249 - 250 - birdie.snap(title: "Multiple related types", content: serialized) 251 - } 252 - 253 - // ===== Empty Types (Edge Cases) ===== 254 - 255 - pub fn empty_input_object_test() { 256 - let empty_input = schema.input_object_type("EmptyInput", "An empty input", []) 257 - 258 - let serialized = sdl.print_type(empty_input) 259 - 260 - birdie.snap(title: "Empty input object", content: serialized) 261 - } 262 - 263 - pub fn empty_enum_test() { 264 - let empty_enum = schema.enum_type("EmptyEnum", "An empty enum", []) 265 - 266 - let serialized = sdl.print_type(empty_enum) 267 - 268 - birdie.snap(title: "Empty enum", content: serialized) 269 - }
···
-66
graphql/test/subscription_parser_test.gleam
··· 1 - /// Snapshot tests for subscription parsing 2 - import birdie 3 - import gleam/string 4 - import gleeunit 5 - import graphql/parser 6 - 7 - pub fn main() { 8 - gleeunit.main() 9 - } 10 - 11 - // Helper to format AST as string for snapshots 12 - fn format_ast(doc: parser.Document) -> String { 13 - string.inspect(doc) 14 - } 15 - 16 - pub fn parse_simple_anonymous_subscription_test() { 17 - let query = "subscription { messageAdded { content author } }" 18 - 19 - let doc = case parser.parse(query) { 20 - Ok(d) -> d 21 - Error(_) -> panic as "Parse failed" 22 - } 23 - 24 - birdie.snap(title: "Simple anonymous subscription", content: format_ast(doc)) 25 - } 26 - 27 - pub fn parse_named_subscription_test() { 28 - let query = "subscription OnMessage { messageAdded { id content } }" 29 - 30 - let doc = case parser.parse(query) { 31 - Ok(d) -> d 32 - Error(_) -> panic as "Parse failed" 33 - } 34 - 35 - birdie.snap(title: "Named subscription", content: format_ast(doc)) 36 - } 37 - 38 - pub fn parse_subscription_with_nested_selections_test() { 39 - let query = 40 - " 41 - subscription { 42 - postCreated { 43 - id 44 - title 45 - author { 46 - id 47 - name 48 - email 49 - } 50 - comments { 51 - content 52 - } 53 - } 54 - } 55 - " 56 - 57 - let doc = case parser.parse(query) { 58 - Ok(d) -> d 59 - Error(_) -> panic as "Parse failed" 60 - } 61 - 62 - birdie.snap( 63 - title: "Subscription with nested selections", 64 - content: format_ast(doc), 65 - ) 66 - }
···
-292
graphql/test/subscription_test.gleam
··· 1 - import gleam/list 2 - import gleam/option.{None, Some} 3 - import gleeunit 4 - import gleeunit/should 5 - import graphql/executor 6 - import graphql/schema 7 - import graphql/value 8 - 9 - pub fn main() { 10 - gleeunit.main() 11 - } 12 - 13 - // Test: Create a subscription type 14 - pub fn create_subscription_type_test() { 15 - let subscription_field = 16 - schema.field( 17 - "testSubscription", 18 - schema.string_type(), 19 - "A test subscription", 20 - fn(_ctx) { Ok(value.String("test")) }, 21 - ) 22 - 23 - let subscription_type = 24 - schema.object_type("Subscription", "Root subscription type", [ 25 - subscription_field, 26 - ]) 27 - 28 - schema.type_name(subscription_type) 29 - |> should.equal("Subscription") 30 - } 31 - 32 - // Test: Schema with subscription type 33 - pub fn schema_with_subscription_test() { 34 - let query_field = 35 - schema.field( 36 - "hello", 37 - schema.string_type(), 38 - "Hello query", 39 - fn(_ctx) { Ok(value.String("world")) }, 40 - ) 41 - 42 - let query_type = 43 - schema.object_type("Query", "Root query type", [query_field]) 44 - 45 - let subscription_field = 46 - schema.field( 47 - "messageAdded", 48 - schema.string_type(), 49 - "Subscribe to new messages", 50 - fn(_ctx) { Ok(value.String("test message")) }, 51 - ) 52 - 53 - let subscription_type = 54 - schema.object_type("Subscription", "Root subscription type", [ 55 - subscription_field, 56 - ]) 57 - 58 - let test_schema = 59 - schema.schema_with_subscriptions(query_type, None, Some(subscription_type)) 60 - 61 - // Schema should be created successfully 62 - // We can't easily test inequality on opaque types, so just verify it doesn't crash 63 - let _ = test_schema 64 - should.be_true(True) 65 - } 66 - 67 - // Test: Get subscription fields 68 - pub fn get_subscription_fields_test() { 69 - let subscription_field1 = 70 - schema.field( 71 - "postCreated", 72 - schema.string_type(), 73 - "New post created", 74 - fn(_ctx) { Ok(value.String("post1")) }, 75 - ) 76 - 77 - let subscription_field2 = 78 - schema.field( 79 - "postUpdated", 80 - schema.string_type(), 81 - "Post updated", 82 - fn(_ctx) { Ok(value.String("post1")) }, 83 - ) 84 - 85 - let subscription_type = 86 - schema.object_type("Subscription", "Root subscription type", [ 87 - subscription_field1, 88 - subscription_field2, 89 - ]) 90 - 91 - let fields = schema.get_fields(subscription_type) 92 - 93 - list.length(fields) 94 - |> should.equal(2) 95 - } 96 - 97 - // Test: Execute anonymous subscription 98 - pub fn execute_anonymous_subscription_test() { 99 - let query_type = 100 - schema.object_type("Query", "Root query", [ 101 - schema.field("dummy", schema.string_type(), "Dummy", fn(_) { 102 - Ok(value.String("dummy")) 103 - }), 104 - ]) 105 - 106 - let message_type = 107 - schema.object_type("Message", "A message", [ 108 - schema.field("content", schema.string_type(), "Message content", fn(ctx) { 109 - case ctx.data { 110 - Some(value.Object(fields)) -> { 111 - case list.key_find(fields, "content") { 112 - Ok(content) -> Ok(content) 113 - Error(_) -> Ok(value.String("")) 114 - } 115 - } 116 - _ -> Ok(value.String("")) 117 - } 118 - }), 119 - ]) 120 - 121 - let subscription_type = 122 - schema.object_type("Subscription", "Root subscription", [ 123 - schema.field("messageAdded", message_type, "New message", fn(ctx) { 124 - // In real usage, this would be called with event data in ctx.data 125 - case ctx.data { 126 - Some(data) -> Ok(data) 127 - None -> Ok(value.Object([#("content", value.String("test"))])) 128 - } 129 - }), 130 - ]) 131 - 132 - let test_schema = 133 - schema.schema_with_subscriptions(query_type, None, Some(subscription_type)) 134 - 135 - // Create context with event data 136 - let event_data = 137 - value.Object([#("content", value.String("Hello from subscription!"))]) 138 - let ctx = schema.context(Some(event_data)) 139 - 140 - let query = "subscription { messageAdded { content } }" 141 - 142 - case executor.execute(query, test_schema, ctx) { 143 - Ok(response) -> { 144 - case response.data { 145 - value.Object(fields) -> { 146 - case list.key_find(fields, "messageAdded") { 147 - Ok(value.Object(message_fields)) -> { 148 - case list.key_find(message_fields, "content") { 149 - Ok(value.String(content)) -> 150 - should.equal(content, "Hello from subscription!") 151 - _ -> should.fail() 152 - } 153 - } 154 - _ -> should.fail() 155 - } 156 - } 157 - _ -> should.fail() 158 - } 159 - } 160 - Error(_) -> should.fail() 161 - } 162 - } 163 - 164 - // Test: Execute subscription with field selection 165 - pub fn execute_subscription_with_field_selection_test() { 166 - let query_type = 167 - schema.object_type("Query", "Root query", [ 168 - schema.field("dummy", schema.string_type(), "Dummy", fn(_) { 169 - Ok(value.String("dummy")) 170 - }), 171 - ]) 172 - 173 - let post_type = 174 - schema.object_type("Post", "A post", [ 175 - schema.field("id", schema.id_type(), "Post ID", fn(ctx) { 176 - case ctx.data { 177 - Some(value.Object(fields)) -> { 178 - case list.key_find(fields, "id") { 179 - Ok(id) -> Ok(id) 180 - Error(_) -> Ok(value.String("")) 181 - } 182 - } 183 - _ -> Ok(value.String("")) 184 - } 185 - }), 186 - schema.field("title", schema.string_type(), "Post title", fn(ctx) { 187 - case ctx.data { 188 - Some(value.Object(fields)) -> { 189 - case list.key_find(fields, "title") { 190 - Ok(title) -> Ok(title) 191 - Error(_) -> Ok(value.String("")) 192 - } 193 - } 194 - _ -> Ok(value.String("")) 195 - } 196 - }), 197 - schema.field("content", schema.string_type(), "Post content", fn(ctx) { 198 - case ctx.data { 199 - Some(value.Object(fields)) -> { 200 - case list.key_find(fields, "content") { 201 - Ok(content) -> Ok(content) 202 - Error(_) -> Ok(value.String("")) 203 - } 204 - } 205 - _ -> Ok(value.String("")) 206 - } 207 - }), 208 - ]) 209 - 210 - let subscription_type = 211 - schema.object_type("Subscription", "Root subscription", [ 212 - schema.field("postCreated", post_type, "New post", fn(ctx) { 213 - case ctx.data { 214 - Some(data) -> Ok(data) 215 - None -> 216 - Ok( 217 - value.Object([ 218 - #("id", value.String("1")), 219 - #("title", value.String("Test")), 220 - #("content", value.String("Test content")), 221 - ]), 222 - ) 223 - } 224 - }), 225 - ]) 226 - 227 - let test_schema = 228 - schema.schema_with_subscriptions(query_type, None, Some(subscription_type)) 229 - 230 - // Create context with event data 231 - let event_data = 232 - value.Object([ 233 - #("id", value.String("123")), 234 - #("title", value.String("New Post")), 235 - #("content", value.String("This is a new post")), 236 - ]) 237 - let ctx = schema.context(Some(event_data)) 238 - 239 - // Query only for id and title, not content 240 - let query = "subscription { postCreated { id title } }" 241 - 242 - case executor.execute(query, test_schema, ctx) { 243 - Ok(response) -> { 244 - case response.data { 245 - value.Object(fields) -> { 246 - case list.key_find(fields, "postCreated") { 247 - Ok(value.Object(post_fields)) -> { 248 - // Should have id and title 249 - case list.key_find(post_fields, "id") { 250 - Ok(value.String(id)) -> should.equal(id, "123") 251 - _ -> should.fail() 252 - } 253 - case list.key_find(post_fields, "title") { 254 - Ok(value.String(title)) -> should.equal(title, "New Post") 255 - _ -> should.fail() 256 - } 257 - // Should NOT have content (field selection working) 258 - case list.key_find(post_fields, "content") { 259 - Error(_) -> should.be_true(True) 260 - Ok(_) -> should.fail() 261 - } 262 - } 263 - _ -> should.fail() 264 - } 265 - } 266 - _ -> should.fail() 267 - } 268 - } 269 - Error(_) -> should.fail() 270 - } 271 - } 272 - 273 - // Test: Subscription without schema type 274 - pub fn subscription_without_schema_type_test() { 275 - let query_type = 276 - schema.object_type("Query", "Root query", [ 277 - schema.field("dummy", schema.string_type(), "Dummy", fn(_) { 278 - Ok(value.String("dummy")) 279 - }), 280 - ]) 281 - 282 - // Schema WITHOUT subscription type 283 - let test_schema = schema.schema(query_type, None) 284 - 285 - let ctx = schema.context(None) 286 - let query = "subscription { messageAdded }" 287 - 288 - case executor.execute(query, test_schema, ctx) { 289 - Error(msg) -> should.equal(msg, "Schema does not define a subscription type") 290 - Ok(_) -> should.fail() 291 - } 292 - }
···
-87
graphql/test/value_test.gleam
··· 1 - /// Tests for GraphQL Value types 2 - /// 3 - /// GraphQL spec Section 2 - Language 4 - /// Values can be: Null, Int, Float, String, Boolean, Enum, List, Object 5 - import gleeunit/should 6 - import graphql/value.{Boolean, Enum, Float, Int, List, Null, Object, String} 7 - 8 - pub fn null_value_test() { 9 - let val = Null 10 - should.equal(val, Null) 11 - } 12 - 13 - pub fn int_value_test() { 14 - let val = Int(42) 15 - should.equal(val, Int(42)) 16 - } 17 - 18 - pub fn float_value_test() { 19 - let val = Float(3.14) 20 - should.equal(val, Float(3.14)) 21 - } 22 - 23 - pub fn string_value_test() { 24 - let val = String("hello") 25 - should.equal(val, String("hello")) 26 - } 27 - 28 - pub fn boolean_true_value_test() { 29 - let val = Boolean(True) 30 - should.equal(val, Boolean(True)) 31 - } 32 - 33 - pub fn boolean_false_value_test() { 34 - let val = Boolean(False) 35 - should.equal(val, Boolean(False)) 36 - } 37 - 38 - pub fn enum_value_test() { 39 - let val = Enum("ACTIVE") 40 - should.equal(val, Enum("ACTIVE")) 41 - } 42 - 43 - pub fn empty_list_value_test() { 44 - let val = List([]) 45 - should.equal(val, List([])) 46 - } 47 - 48 - pub fn list_of_ints_test() { 49 - let val = List([Int(1), Int(2), Int(3)]) 50 - should.equal(val, List([Int(1), Int(2), Int(3)])) 51 - } 52 - 53 - pub fn nested_list_test() { 54 - let val = List([List([Int(1), Int(2)]), List([Int(3), Int(4)])]) 55 - should.equal(val, List([List([Int(1), Int(2)]), List([Int(3), Int(4)])])) 56 - } 57 - 58 - pub fn empty_object_test() { 59 - let val = Object([]) 60 - should.equal(val, Object([])) 61 - } 62 - 63 - pub fn simple_object_test() { 64 - let val = Object([#("name", String("Alice")), #("age", Int(30))]) 65 - should.equal(val, Object([#("name", String("Alice")), #("age", Int(30))])) 66 - } 67 - 68 - pub fn nested_object_test() { 69 - let val = 70 - Object([ 71 - #("user", Object([#("name", String("Bob")), #("active", Boolean(True))])), 72 - #("count", Int(5)), 73 - ]) 74 - 75 - should.equal( 76 - val, 77 - Object([ 78 - #("user", Object([#("name", String("Bob")), #("active", Boolean(True))])), 79 - #("count", Int(5)), 80 - ]), 81 - ) 82 - } 83 - 84 - pub fn mixed_types_list_test() { 85 - let val = List([String("hello"), Int(42), Boolean(True), Null]) 86 - should.equal(val, List([String("hello"), Int(42), Boolean(True), Null])) 87 - }
···
+5 -5
lexicon_graphql/README.md
··· 43 ```gleam 44 import lexicon_graphql 45 import lexicon_graphql/db_schema_builder 46 - import graphql/schema 47 48 // Parse a lexicon file 49 let lexicon_json = "{ \"lexicon\": 1, \"id\": \"xyz.statusphere.status\", ... }" ··· 152 153 ## Testing 154 155 - The package uses the `graphql` package's test suite to verify schema generation and execution. 156 157 ## Dependencies 158 159 - `gleam_stdlib` >= 0.44.0 160 - `gleam_json` >= 3.0.0 161 - - `graphql` (local package) 162 163 ## Integration Example 164 165 ```gleam 166 import lexicon_graphql/db_schema_builder 167 import database 168 - import graphql/schema 169 - import graphql/executor 170 171 // Load lexicon 172 let lexicon_json = load_lexicon("priv/lexicons/xyz/statusphere/status.json")
··· 43 ```gleam 44 import lexicon_graphql 45 import lexicon_graphql/db_schema_builder 46 + import swell/schema 47 48 // Parse a lexicon file 49 let lexicon_json = "{ \"lexicon\": 1, \"id\": \"xyz.statusphere.status\", ... }" ··· 152 153 ## Testing 154 155 + The package uses the `swell` package's test suite to verify schema generation and execution. 156 157 ## Dependencies 158 159 - `gleam_stdlib` >= 0.44.0 160 - `gleam_json` >= 3.0.0 161 + - `swell` >= 1.0.0 162 163 ## Integration Example 164 165 ```gleam 166 import lexicon_graphql/db_schema_builder 167 import database 168 + import swell/schema 169 + import swell/executor 170 171 // Load lexicon 172 let lexicon_json = load_lexicon("priv/lexicons/xyz/statusphere/status.json")
+1 -1
lexicon_graphql/gleam.toml
··· 15 [dependencies] 16 gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 gleam_json = ">= 3.0.0 and < 4.0.0" 18 - graphql = {path = "../graphql"} 19 20 [dev-dependencies] 21 gleeunit = ">= 1.0.0 and < 2.0.0"
··· 15 [dependencies] 16 gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 gleam_json = ">= 3.0.0 and < 4.0.0" 18 + swell = ">= 1.0.0 and < 2.0.0" 19 20 [dev-dependencies] 21 gleeunit = ">= 1.0.0 and < 2.0.0"
+2 -2
lexicon_graphql/manifest.toml
··· 14 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 15 { name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" }, 16 { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 17 - { name = "graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../graphql" }, 18 { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 19 { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 20 { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 21 { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, 22 { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 23 { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 24 ] ··· 28 gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 29 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 30 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 31 - graphql = { path = "../graphql" }
··· 14 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 15 { name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" }, 16 { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, 17 { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, 18 { name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" }, 19 { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 20 { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, 21 + { name = "swell", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "7CCA8C61349396C5B59B3C0627185F5B30917044E0D61CB7E0E5CC75C1B4A8E9" }, 22 { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 23 { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 24 ] ··· 28 gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 29 gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 30 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 31 + swell = { version = ">= 1.0.0 and < 2.0.0" }
+2 -2
lexicon_graphql/src/lexicon_graphql/blob_type.gleam
··· 40 import gleam/list 41 import gleam/option.{Some} 42 import gleam/result 43 - import graphql/schema 44 - import graphql/value 45 46 /// Create the Blob GraphQL object type (output type) 47 pub fn create_blob_type() -> schema.Type {
··· 40 import gleam/list 41 import gleam/option.{Some} 42 import gleam/result 43 + import swell/schema 44 + import swell/value 45 46 /// Create the Blob GraphQL object type (output type) 47 pub fn create_blob_type() -> schema.Type {
+2 -2
lexicon_graphql/src/lexicon_graphql/connection.gleam
··· 3 /// Adds sortBy support to the base Relay Connection specification 4 import gleam/list 5 import gleam/option.{None} 6 - import graphql/connection 7 - import graphql/schema 8 9 /// SortDirection enum type for lexicon queries 10 pub fn sort_direction_enum() -> schema.Type {
··· 3 /// Adds sortBy support to the base Relay Connection specification 4 import gleam/list 5 import gleam/option.{None} 6 + import swell/connection 7 + import swell/schema 8 9 /// SortDirection enum type for lexicon queries 10 pub fn sort_direction_enum() -> schema.Type {
+1 -1
lexicon_graphql/src/lexicon_graphql/dataloader.gleam
··· 8 import gleam/option.{type Option, None, Some} 9 import gleam/result 10 import gleam/string 11 - import graphql/value 12 import lexicon_graphql/collection_meta 13 import lexicon_graphql/uri_extractor 14 import lexicon_graphql/where_input.{type WhereClause} 15 16 /// Result of a batch query: maps URIs to their records 17 pub type BatchResult =
··· 8 import gleam/option.{type Option, None, Some} 9 import gleam/result 10 import gleam/string 11 import lexicon_graphql/collection_meta 12 import lexicon_graphql/uri_extractor 13 import lexicon_graphql/where_input.{type WhereClause} 14 + import swell/value 15 16 /// Result of a batch query: maps URIs to their records 17 pub type BatchResult =
+10 -5
lexicon_graphql/src/lexicon_graphql/db_schema_builder.gleam
··· 9 import gleam/option 10 import gleam/result 11 import gleam/string 12 - import graphql/connection 13 - import graphql/schema 14 - import graphql/value 15 import lexicon_graphql/collection_meta 16 import lexicon_graphql/connection as lexicon_connection 17 import lexicon_graphql/dataloader ··· 23 import lexicon_graphql/types 24 import lexicon_graphql/uri_extractor 25 import lexicon_graphql/where_input 26 27 /// Represents a reverse join relationship discovered from lexicon analysis 28 type ReverseJoinRelationship { ··· 155 ) 156 157 // Build the subscription type 158 - let subscription_type = build_subscription_type(record_types, object_types) 159 160 // Create the schema with queries, mutations, and subscriptions 161 Ok(schema.schema_with_subscriptions( ··· 1706 [created_field, updated_field, deleted_field] 1707 }) 1708 1709 - schema.object_type("Subscription", "GraphQL subscription root", subscription_fields) 1710 }
··· 9 import gleam/option 10 import gleam/result 11 import gleam/string 12 import lexicon_graphql/collection_meta 13 import lexicon_graphql/connection as lexicon_connection 14 import lexicon_graphql/dataloader ··· 20 import lexicon_graphql/types 21 import lexicon_graphql/uri_extractor 22 import lexicon_graphql/where_input 23 + import swell/connection 24 + import swell/schema 25 + import swell/value 26 27 /// Represents a reverse join relationship discovered from lexicon analysis 28 type ReverseJoinRelationship { ··· 155 ) 156 157 // Build the subscription type 158 + let subscription_type = 159 + build_subscription_type(record_types, object_types) 160 161 // Create the schema with queries, mutations, and subscriptions 162 Ok(schema.schema_with_subscriptions( ··· 1707 [created_field, updated_field, deleted_field] 1708 }) 1709 1710 + schema.object_type( 1711 + "Subscription", 1712 + "GraphQL subscription root", 1713 + subscription_fields, 1714 + ) 1715 }
+2 -2
lexicon_graphql/src/lexicon_graphql/mutation_builder.gleam
··· 8 import gleam/dict 9 import gleam/list 10 import gleam/option 11 - import graphql/schema 12 - import graphql/value 13 import lexicon_graphql/nsid 14 import lexicon_graphql/type_mapper 15 import lexicon_graphql/types 16 17 /// Resolver factory function type 18 /// Takes collection name and returns a resolver function
··· 8 import gleam/dict 9 import gleam/list 10 import gleam/option 11 import lexicon_graphql/nsid 12 import lexicon_graphql/type_mapper 13 import lexicon_graphql/types 14 + import swell/schema 15 + import swell/value 16 17 /// Resolver factory function type 18 /// Takes collection name and returns a resolver function
+2 -2
lexicon_graphql/src/lexicon_graphql/object_type_builder.gleam
··· 7 import gleam/list 8 import gleam/option 9 import gleam/string 10 - import graphql/schema 11 - import graphql/value 12 import lexicon_graphql/lexicon_registry 13 import lexicon_graphql/nsid 14 import lexicon_graphql/type_mapper 15 import lexicon_graphql/types 16 17 /// Build a GraphQL object type from an ObjectDef 18 /// object_types_dict is used to resolve refs to other object types
··· 7 import gleam/list 8 import gleam/option 9 import gleam/string 10 import lexicon_graphql/lexicon_registry 11 import lexicon_graphql/nsid 12 import lexicon_graphql/type_mapper 13 import lexicon_graphql/types 14 + import swell/schema 15 + import swell/value 16 17 /// Build a GraphQL object type from an ObjectDef 18 /// object_types_dict is used to resolve refs to other object types
+2 -2
lexicon_graphql/src/lexicon_graphql/schema_builder.gleam
··· 5 import gleam/dict 6 import gleam/list 7 import gleam/option 8 - import graphql/schema 9 - import graphql/value 10 import lexicon_graphql/mutation_builder 11 import lexicon_graphql/nsid 12 import lexicon_graphql/type_mapper 13 import lexicon_graphql/types 14 15 /// Re-export types for backwards compatibility 16 pub type Lexicon =
··· 5 import gleam/dict 6 import gleam/list 7 import gleam/option 8 import lexicon_graphql/mutation_builder 9 import lexicon_graphql/nsid 10 import lexicon_graphql/type_mapper 11 import lexicon_graphql/types 12 + import swell/schema 13 + import swell/value 14 15 /// Re-export types for backwards compatibility 16 pub type Lexicon =
+1 -1
lexicon_graphql/src/lexicon_graphql/type_mapper.gleam
··· 6 /// Based on the Elixir implementation but adapted for the pure Gleam GraphQL library. 7 import gleam/dict.{type Dict} 8 import gleam/option.{type Option} 9 - import graphql/schema 10 import lexicon_graphql/blob_type 11 12 /// Maps a lexicon type string to a GraphQL output Type. 13 ///
··· 6 /// Based on the Elixir implementation but adapted for the pure Gleam GraphQL library. 7 import gleam/dict.{type Dict} 8 import gleam/option.{type Option} 9 import lexicon_graphql/blob_type 10 + import swell/schema 11 12 /// Maps a lexicon type string to a GraphQL output Type. 13 ///
+1 -1
lexicon_graphql/src/lexicon_graphql/where_input.gleam
··· 5 import gleam/dict.{type Dict} 6 import gleam/list 7 import gleam/option.{type Option, None, Some} 8 - import graphql/value 9 10 /// Simple value type that can represent strings, ints, or other primitives 11 pub type WhereValue {
··· 5 import gleam/dict.{type Dict} 6 import gleam/list 7 import gleam/option.{type Option, None, Some} 8 + import swell/value 9 10 /// Simple value type that can represent strings, ints, or other primitives 11 pub type WhereValue {
+2 -2
lexicon_graphql/test/blob_type_test.gleam
··· 4 import gleam/dict 5 import gleam/option.{Some} 6 import gleeunit/should 7 - import graphql/schema 8 - import graphql/value 9 import lexicon_graphql/blob_type 10 11 pub fn create_blob_type_test() { 12 let blob_type = blob_type.create_blob_type()
··· 4 import gleam/dict 5 import gleam/option.{Some} 6 import gleeunit/should 7 import lexicon_graphql/blob_type 8 + import swell/schema 9 + import swell/value 10 11 pub fn create_blob_type_test() { 12 let blob_type = blob_type.create_blob_type()
+1 -1
lexicon_graphql/test/connection_test.gleam
··· 2 /// 3 /// Tests the creation of unique SortFieldInput types per collection 4 import gleeunit/should 5 - import graphql/schema 6 import lexicon_graphql/connection as lexicon_connection 7 8 pub fn sort_field_input_type_with_enum_creates_types_test() { 9 // Test: sort_field_input_type_with_enum should create input object types
··· 2 /// 3 /// Tests the creation of unique SortFieldInput types per collection 4 import gleeunit/should 5 import lexicon_graphql/connection as lexicon_connection 6 + import swell/schema 7 8 pub fn sort_field_input_type_with_enum_creates_types_test() { 9 // Test: sort_field_input_type_with_enum should create input object types
+1 -1
lexicon_graphql/test/dataloader_test.gleam
··· 6 import gleam/list 7 import gleam/option.{None, Some} 8 import gleeunit/should 9 - import graphql/value 10 import lexicon_graphql/collection_meta 11 import lexicon_graphql/dataloader 12 import lexicon_graphql/types 13 14 // Test URI-to-collection extraction 15 pub fn uri_to_collection_test() {
··· 6 import gleam/list 7 import gleam/option.{None, Some} 8 import gleeunit/should 9 import lexicon_graphql/collection_meta 10 import lexicon_graphql/dataloader 11 import lexicon_graphql/types 12 + import swell/value 13 14 // Test URI-to-collection extraction 15 pub fn uri_to_collection_test() {
+3 -3
lexicon_graphql/test/did_join_test.gleam
··· 8 import gleam/option 9 import gleam/string 10 import gleeunit/should 11 - import graphql/introspection 12 - import graphql/schema 13 - import graphql/sdl 14 import lexicon_graphql/db_schema_builder 15 import lexicon_graphql/types 16 17 // Helper to create a test schema with a mock fetcher 18 fn create_test_schema_from_lexicons(
··· 8 import gleam/option 9 import gleam/string 10 import gleeunit/should 11 import lexicon_graphql/db_schema_builder 12 import lexicon_graphql/types 13 + import swell/introspection 14 + import swell/schema 15 + import swell/sdl 16 17 // Helper to create a test schema with a mock fetcher 18 fn create_test_schema_from_lexicons(
+3 -3
lexicon_graphql/test/forward_join_test.gleam
··· 5 import gleam/option.{None, Some} 6 import gleam/string 7 import gleeunit/should 8 - import graphql/introspection 9 - import graphql/schema 10 - import graphql/sdl 11 import lexicon_graphql/db_schema_builder 12 import lexicon_graphql/types 13 14 // Helper to create a test schema with a mock fetcher 15 fn create_test_schema_from_lexicons(
··· 5 import gleam/option.{None, Some} 6 import gleam/string 7 import gleeunit/should 8 import lexicon_graphql/db_schema_builder 9 import lexicon_graphql/types 10 + import swell/introspection 11 + import swell/schema 12 + import swell/sdl 13 14 // Helper to create a test schema with a mock fetcher 15 fn create_test_schema_from_lexicons(
+2 -2
lexicon_graphql/test/mutation_builder_test.gleam
··· 5 import gleam/list 6 import gleam/option.{None, Some} 7 import gleeunit/should 8 - import graphql/schema 9 - import graphql/value 10 import lexicon_graphql/mutation_builder 11 12 /// Test that uploadBlob mutation is added when factory is provided 13 pub fn build_mutation_type_includes_upload_blob_test() {
··· 5 import gleam/list 6 import gleam/option.{None, Some} 7 import gleeunit/should 8 import lexicon_graphql/mutation_builder 9 + import swell/schema 10 + import swell/value 11 12 /// Test that uploadBlob mutation is added when factory is provided 13 pub fn build_mutation_type_includes_upload_blob_test() {
+3 -3
lexicon_graphql/test/reverse_join_test.gleam
··· 5 import gleam/option.{None, Some} 6 import gleam/string 7 import gleeunit/should 8 - import graphql/introspection 9 - import graphql/schema 10 - import graphql/sdl 11 import lexicon_graphql/db_schema_builder 12 import lexicon_graphql/types 13 14 // Helper to create a test schema with a mock fetcher 15 fn create_test_schema_from_lexicons(
··· 5 import gleam/option.{None, Some} 6 import gleam/string 7 import gleeunit/should 8 import lexicon_graphql/db_schema_builder 9 import lexicon_graphql/types 10 + import swell/introspection 11 + import swell/schema 12 + import swell/sdl 13 14 // Helper to create a test schema with a mock fetcher 15 fn create_test_schema_from_lexicons(
+3 -3
lexicon_graphql/test/schema_builder_test.gleam
··· 6 import gleam/dict 7 import gleam/option.{None, Some} 8 import gleeunit/should 9 - import graphql/introspection 10 - import graphql/schema 11 - import graphql/sdl 12 import lexicon_graphql/schema_builder 13 import lexicon_graphql/types 14 15 // Test building a schema from a simple lexicon 16 pub fn simple_schema_snapshot_test() {
··· 6 import gleam/dict 7 import gleam/option.{None, Some} 8 import gleeunit/should 9 import lexicon_graphql/schema_builder 10 import lexicon_graphql/types 11 + import swell/introspection 12 + import swell/schema 13 + import swell/sdl 14 15 // Test building a schema from a simple lexicon 16 pub fn simple_schema_snapshot_test() {
+3 -3
lexicon_graphql/test/sorting_test.gleam
··· 12 import gleam/list 13 import gleam/option.{None, Some} 14 import gleeunit/should 15 - import graphql/introspection 16 - import graphql/schema 17 - import graphql/sdl 18 import lexicon_graphql/db_schema_builder 19 import lexicon_graphql/schema_builder 20 import lexicon_graphql/types 21 22 // Helper to create a test schema with a mock fetcher 23 fn create_test_schema_from_lexicons(
··· 12 import gleam/list 13 import gleam/option.{None, Some} 14 import gleeunit/should 15 import lexicon_graphql/db_schema_builder 16 import lexicon_graphql/schema_builder 17 import lexicon_graphql/types 18 + import swell/introspection 19 + import swell/schema 20 + import swell/sdl 21 22 // Helper to create a test schema with a mock fetcher 23 fn create_test_schema_from_lexicons(
+1 -1
lexicon_graphql/test/subscription_schema_test.gleam
··· 7 import gleam/string 8 import gleeunit 9 import gleeunit/should 10 - import graphql/schema 11 import lexicon_graphql/db_schema_builder 12 import lexicon_graphql/types 13 14 pub fn main() { 15 gleeunit.main()
··· 7 import gleam/string 8 import gleeunit 9 import gleeunit/should 10 import lexicon_graphql/db_schema_builder 11 import lexicon_graphql/types 12 + import swell/schema 13 14 pub fn main() { 15 gleeunit.main()
+1 -1
lexicon_graphql/test/type_mapper_test.gleam
··· 2 /// 3 /// Maps AT Protocol lexicon types to GraphQL types 4 import gleeunit/should 5 - import graphql/schema 6 import lexicon_graphql/type_mapper 7 8 pub fn map_string_type_test() { 9 type_mapper.map_type("string")
··· 2 /// 3 /// Maps AT Protocol lexicon types to GraphQL types 4 import gleeunit/should 5 import lexicon_graphql/type_mapper 6 + import swell/schema 7 8 pub fn map_string_type_test() { 9 type_mapper.map_type("string")
+1 -1
lexicon_graphql/test/where_input_test.gleam
··· 6 import gleam/option.{None, Some} 7 import gleeunit 8 import gleeunit/should 9 - import graphql/value 10 import lexicon_graphql/where_input 11 12 pub fn main() { 13 gleeunit.main()
··· 6 import gleam/option.{None, Some} 7 import gleeunit 8 import gleeunit/should 9 import lexicon_graphql/where_input 10 + import swell/value 11 12 pub fn main() { 13 gleeunit.main()
+3 -3
lexicon_graphql/test/where_schema_test.gleam
··· 8 import gleam/option.{None, Some} 9 import gleeunit 10 import gleeunit/should 11 - import graphql/introspection 12 - import graphql/schema 13 - import graphql/sdl 14 import lexicon_graphql/connection 15 import lexicon_graphql/db_schema_builder 16 import lexicon_graphql/types 17 18 pub fn main() { 19 gleeunit.main()
··· 8 import gleam/option.{None, Some} 9 import gleeunit 10 import gleeunit/should 11 import lexicon_graphql/connection 12 import lexicon_graphql/db_schema_builder 13 import lexicon_graphql/types 14 + import swell/introspection 15 + import swell/schema 16 + import swell/sdl 17 18 pub fn main() { 19 gleeunit.main()
+1 -1
server/gleam.toml
··· 14 15 [dependencies] 16 lexicon = { path = "../lexicon" } 17 - graphql = { path = "../graphql" } 18 lexicon_graphql = { path = "../lexicon_graphql" } 19 gleam_stdlib = ">= 0.60.0 and < 1.0.0" 20 mist = ">= 5.0.3 and < 6.0.0" ··· 39 gleam_crypto = ">= 1.5.1 and < 2.0.0" 40 logging = ">= 1.3.0 and < 2.0.0" 41 group_registry = ">= 1.0.0 and < 2.0.0" 42 43 [dev-dependencies] 44 gleeunit = ">= 1.0.0 and < 2.0.0"
··· 14 15 [dependencies] 16 lexicon = { path = "../lexicon" } 17 lexicon_graphql = { path = "../lexicon_graphql" } 18 gleam_stdlib = ">= 0.60.0 and < 1.0.0" 19 mist = ">= 5.0.3 and < 6.0.0" ··· 38 gleam_crypto = ">= 1.5.1 and < 2.0.0" 39 logging = ">= 1.3.0 and < 2.0.0" 40 group_registry = ">= 1.0.0 and < 2.0.0" 41 + swell = ">= 1.0.0 and < 2.0.0" 42 43 [dev-dependencies] 44 gleeunit = ">= 1.0.0 and < 2.0.0"
+5 -5
server/manifest.toml
··· 21 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 22 { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, 23 { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 24 - { name = "gleeunit", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "7AE0F64B26CC065ED705FF7CA5F4EDAB8015E72A883736FE251E46FACCCE1E08" }, 25 { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, 26 { name = "goose", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "ezstd", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gramps", "logging", "simplifile"], otp_app = "goose", source = "hex", outer_checksum = "E991B275766D28693B8179EF77ADCCD210D58C1D3E3A1B4539C228D6CE58845B" }, 27 { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 28 - { name = "graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../graphql" }, 29 { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 30 { name = "hackney", version = "1.25.0", build_tools = ["rebar3"], requirements = ["certifi", "idna", "metrics", "mimerl", "parse_trans", "ssl_verify_fun", "unicode_util_compat"], otp_app = "hackney", source = "hex", outer_checksum = "7209BFD75FD1F42467211FF8F59EA74D6F2A9E81CBCEE95A56711EE79FD6B1D4" }, 31 { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, ··· 33 { name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" }, 34 { name = "jose", version = "1.11.10", build_tools = ["mix", "rebar3"], requirements = [], otp_app = "jose", source = "hex", outer_checksum = "0D6CD36FF8BA174DB29148FC112B5842186B68A90CE9FC2B3EC3AFE76593E614" }, 35 { name = "lexicon", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../lexicon" }, 36 - { name = "lexicon_graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "graphql"], source = "local", path = "../lexicon_graphql" }, 37 { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 38 { name = "lustre", version = "5.4.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "40E097BABCE65FB7C460C073078611F7F5802EB07E1A9BFB5C229F71B60F8E50" }, 39 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, ··· 42 { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, 43 { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, 44 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 45 - { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 46 { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" }, 47 { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 48 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 49 { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, 50 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, ··· 67 gleam_time = { version = ">= 1.4.0 and < 2.0.0" } 68 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 69 goose = { version = ">= 2.0.0 and < 3.0.0" } 70 - graphql = { path = "../graphql" } 71 group_registry = { version = ">= 1.0.0 and < 2.0.0" } 72 jose = { version = ">= 1.11.10 and < 2.0.0" } 73 lexicon = { path = "../lexicon" } ··· 80 thoas = { version = ">= 1.0.0 and < 2.0.0" } 81 wisp = { version = ">= 2.1.0 and < 3.0.0" } 82 wisp_flash = { version = ">= 2.0.0 and < 3.0.0" }
··· 21 { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 22 { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, 23 { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 24 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 25 { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, 26 { name = "goose", version = "2.0.0", build_tools = ["gleam"], requirements = ["exception", "ezstd", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "gramps", "logging", "simplifile"], otp_app = "goose", source = "hex", outer_checksum = "E991B275766D28693B8179EF77ADCCD210D58C1D3E3A1B4539C228D6CE58845B" }, 27 { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 28 { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, 29 { name = "hackney", version = "1.25.0", build_tools = ["rebar3"], requirements = ["certifi", "idna", "metrics", "mimerl", "parse_trans", "ssl_verify_fun", "unicode_util_compat"], otp_app = "hackney", source = "hex", outer_checksum = "7209BFD75FD1F42467211FF8F59EA74D6F2A9E81CBCEE95A56711EE79FD6B1D4" }, 30 { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, ··· 32 { name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" }, 33 { name = "jose", version = "1.11.10", build_tools = ["mix", "rebar3"], requirements = [], otp_app = "jose", source = "hex", outer_checksum = "0D6CD36FF8BA174DB29148FC112B5842186B68A90CE9FC2B3EC3AFE76593E614" }, 34 { name = "lexicon", version = "0.1.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../lexicon" }, 35 + { name = "lexicon_graphql", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "swell"], source = "local", path = "../lexicon_graphql" }, 36 { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 37 { name = "lustre", version = "5.4.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "40E097BABCE65FB7C460C073078611F7F5802EB07E1A9BFB5C229F71B60F8E50" }, 38 { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, ··· 41 { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, 42 { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, 43 { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, 44 + { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, 45 { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" }, 46 { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 47 + { name = "swell", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "7CCA8C61349396C5B59B3C0627185F5B30917044E0D61CB7E0E5CC75C1B4A8E9" }, 48 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 49 { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, 50 { name = "unicode_util_compat", version = "0.7.1", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642" }, ··· 67 gleam_time = { version = ">= 1.4.0 and < 2.0.0" } 68 gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 69 goose = { version = ">= 2.0.0 and < 3.0.0" } 70 group_registry = { version = ">= 1.0.0 and < 2.0.0" } 71 jose = { version = ">= 1.11.10 and < 2.0.0" } 72 lexicon = { path = "../lexicon" } ··· 79 thoas = { version = ">= 1.0.0 and < 2.0.0" } 80 wisp = { version = ">= 2.1.0 and < 3.0.0" } 81 wisp_flash = { version = ">= 2.0.0 and < 3.0.0" } 82 + swell = { version = ">= 1.0.0 and < 2.0.0" }
+2 -8
server/src/actor_validator.gleam
··· 22 case list.is_empty(actors) { 23 False -> { 24 // Actor exists, nothing to do 25 - logging.log( 26 - logging.Debug, 27 - "Actor already exists: " <> did, 28 - ) 29 Ok(False) 30 } 31 True -> { 32 // Actor not found, need to resolve and create 33 - logging.log( 34 - logging.Info, 35 - "Actor not found, resolving DID: " <> did, 36 - ) 37 38 case backfill.resolve_did(did, plc_url) { 39 Ok(atp_data) -> {
··· 22 case list.is_empty(actors) { 23 False -> { 24 // Actor exists, nothing to do 25 + logging.log(logging.Debug, "Actor already exists: " <> did) 26 Ok(False) 27 } 28 True -> { 29 // Actor not found, need to resolve and create 30 + logging.log(logging.Info, "Actor not found, resolving DID: " <> did) 31 32 case backfill.resolve_did(did, plc_url) { 33 Ok(atp_data) -> {
+94 -55
server/src/backfill.gleam
··· 6 import gleam/erlang/process.{type Subject} 7 import gleam/hackney 8 import gleam/http/request 9 - import logging 10 import gleam/json 11 import gleam/list 12 import gleam/option.{type Option, None, Some} ··· 14 import gleam/string 15 import gleam/time/duration 16 import gleam/time/timestamp 17 import sqlight 18 19 /// Convert a Dynamic value (Erlang term) to JSON string ··· 38 case decode.run(binary, decode.string) { 39 Ok(str) -> str 40 Error(_) -> { 41 - logging.log(logging.Warning, "[backfill] Failed to convert iolist to string") 42 string.inspect(iolist) 43 } 44 } ··· 84 85 /// Check if an NSID matches the configured domain authority 86 /// NSID format is like "com.example.post" where "com.example" is the authority 87 - pub fn nsid_matches_domain_authority(nsid: String, domain_authority: String) -> Bool { 88 // NSID format: authority.name (e.g., "com.example.post") 89 // We need to check if the NSID starts with the domain authority 90 string.starts_with(nsid, domain_authority <> ".") ··· 180 let result = case resolve_did(did, plc_url) { 181 Ok(atp_data) -> Ok(atp_data) 182 Error(err) -> { 183 - logging.log(logging.Error, "[backfill] Error resolving DID " <> did <> ": " <> err) 184 Error(Nil) 185 } 186 } ··· 248 249 case request.to(url) { 250 Error(_) -> { 251 - logging.log(logging.Error, "[backfill] Failed to create request for: " <> url) 252 acc 253 } 254 Ok(req) -> { ··· 266 logging.log( 267 logging.Error, 268 "[backfill] Failed to fetch records for " 269 - <> repo 270 - <> "/" 271 - <> collection 272 - <> ": " 273 - <> err_str, 274 ) 275 } 276 acc ··· 297 logging.log( 298 logging.Error, 299 "[backfill] Failed to parse records for " 300 - <> repo 301 - <> "/" 302 - <> collection 303 - <> ": " 304 - <> err, 305 ) 306 acc 307 } ··· 318 logging.log( 319 logging.Error, 320 "[backfill] Failed to fetch records for " 321 - <> repo 322 - <> "/" 323 - <> collection 324 - <> " (status: " 325 - <> string.inspect(resp.status) 326 - <> ")", 327 ) 328 acc 329 } ··· 357 // Parse the records first 358 case json.parse(body, decoder) { 359 Error(err) -> { 360 - logging.log(logging.Error, "[backfill] Failed to parse records: " <> string.inspect(err)) 361 - logging.log(logging.Error, "[backfill] Response body snippet: " <> string.slice(body, 0, 200)) 362 Error("Failed to parse listRecords response") 363 } 364 Ok(record_tuples) -> { ··· 508 |> list.flat_map(fn(repo) { 509 case list.find(atp_data, fn(data) { data.did == repo }) { 510 Error(_) -> { 511 - logging.log(logging.Error, "[backfill] No ATP data found for repo: " <> repo) 512 [] 513 } 514 Ok(data) -> { ··· 582 Error(err) -> { 583 logging.log( 584 logging.Error, 585 - "[backfill] Failed to upsert actor " <> data.did <> ": " <> string.inspect(err), 586 ) 587 } 588 } ··· 633 index_records(records, db) 634 }) 635 636 - logging.log( 637 - logging.Info, 638 - "[backfill] Completed sync for " <> did, 639 - ) 640 } 641 Error(err) -> { 642 logging.log( ··· 697 logging.log( 698 logging.Info, 699 "[backfill] Found " 700 - <> string.inspect(list.length(new_acc)) 701 - <> " total repositories for collection \"" 702 - <> collection 703 - <> "\"", 704 ) 705 Ok(new_acc) 706 } ··· 772 773 case collections { 774 [] -> { 775 - logging.log(logging.Error, "[backfill] No collections specified for backfill") 776 Nil 777 } 778 _ -> { 779 logging.log( 780 logging.Info, 781 "[backfill] Processing " 782 - <> string.inspect(list.length(collections)) 783 - <> " collections: " 784 - <> string.join(collections, ", "), 785 ) 786 787 run_backfill(repos, collections, external_collections, config, conn) ··· 796 config: BackfillConfig, 797 conn: sqlight.Connection, 798 ) -> Nil { 799 - 800 case external_collections { 801 [] -> Nil 802 _ -> 803 logging.log( 804 logging.Info, 805 "[backfill] Including " 806 - <> string.inspect(list.length(external_collections)) 807 - <> " external collections: " 808 - <> string.join(external_collections, ", "), 809 ) 810 } 811 ··· 813 let all_repos = case repos { 814 [] -> { 815 // Fetch repos for all collections from the relay 816 - logging.log(logging.Info, "[backfill] Fetching repositories for collections...") 817 let fetched_repos = 818 collections 819 |> list.filter_map(fn(collection) { ··· 831 logging.log( 832 logging.Info, 833 "[backfill] Processing " 834 - <> string.inspect(list.length(fetched_repos)) 835 - <> " unique repositories", 836 ) 837 fetched_repos 838 } ··· 840 logging.log( 841 logging.Info, 842 "[backfill] Using " 843 - <> string.inspect(list.length(provided_repos)) 844 - <> " provided repositories", 845 ) 846 provided_repos 847 } ··· 853 logging.log( 854 logging.Info, 855 "[backfill] Resolved ATP data for " 856 - <> string.inspect(list.length(atp_data)) 857 - <> "/" 858 - <> string.inspect(list.length(all_repos)) 859 - <> " repositories", 860 ) 861 862 // Get all records for all repos and collections (main collections only) 863 - logging.log(logging.Info, "[backfill] Fetching records for repositories and collections...") 864 let main_records = 865 get_records_for_repos(all_repos, collections, atp_data, config) 866 ··· 874 let all_records = list.append(main_records, external_records) 875 logging.log( 876 logging.Info, 877 - "[backfill] Fetched " <> string.inspect(list.length(all_records)) <> " total records", 878 ) 879 880 // Index actors (if enabled in config) ··· 884 index_actors(atp_data, conn) 885 logging.log( 886 logging.Info, 887 - "[backfill] Indexed " <> string.inspect(list.length(atp_data)) <> " actors", 888 ) 889 } 890 - False -> logging.log(logging.Info, "[backfill] Skipping actor indexing (disabled in config)") 891 } 892 893 // Index records 894 logging.log( 895 logging.Info, 896 - "[backfill] Indexing " <> string.inspect(list.length(all_records)) <> " records...", 897 ) 898 index_records(all_records, conn) 899 logging.log(logging.Info, "[backfill] Backfill complete!")
··· 6 import gleam/erlang/process.{type Subject} 7 import gleam/hackney 8 import gleam/http/request 9 import gleam/json 10 import gleam/list 11 import gleam/option.{type Option, None, Some} ··· 13 import gleam/string 14 import gleam/time/duration 15 import gleam/time/timestamp 16 + import logging 17 import sqlight 18 19 /// Convert a Dynamic value (Erlang term) to JSON string ··· 38 case decode.run(binary, decode.string) { 39 Ok(str) -> str 40 Error(_) -> { 41 + logging.log( 42 + logging.Warning, 43 + "[backfill] Failed to convert iolist to string", 44 + ) 45 string.inspect(iolist) 46 } 47 } ··· 87 88 /// Check if an NSID matches the configured domain authority 89 /// NSID format is like "com.example.post" where "com.example" is the authority 90 + pub fn nsid_matches_domain_authority( 91 + nsid: String, 92 + domain_authority: String, 93 + ) -> Bool { 94 // NSID format: authority.name (e.g., "com.example.post") 95 // We need to check if the NSID starts with the domain authority 96 string.starts_with(nsid, domain_authority <> ".") ··· 186 let result = case resolve_did(did, plc_url) { 187 Ok(atp_data) -> Ok(atp_data) 188 Error(err) -> { 189 + logging.log( 190 + logging.Error, 191 + "[backfill] Error resolving DID " <> did <> ": " <> err, 192 + ) 193 Error(Nil) 194 } 195 } ··· 257 258 case request.to(url) { 259 Error(_) -> { 260 + logging.log( 261 + logging.Error, 262 + "[backfill] Failed to create request for: " <> url, 263 + ) 264 acc 265 } 266 Ok(req) -> { ··· 278 logging.log( 279 logging.Error, 280 "[backfill] Failed to fetch records for " 281 + <> repo 282 + <> "/" 283 + <> collection 284 + <> ": " 285 + <> err_str, 286 ) 287 } 288 acc ··· 309 logging.log( 310 logging.Error, 311 "[backfill] Failed to parse records for " 312 + <> repo 313 + <> "/" 314 + <> collection 315 + <> ": " 316 + <> err, 317 ) 318 acc 319 } ··· 330 logging.log( 331 logging.Error, 332 "[backfill] Failed to fetch records for " 333 + <> repo 334 + <> "/" 335 + <> collection 336 + <> " (status: " 337 + <> string.inspect(resp.status) 338 + <> ")", 339 ) 340 acc 341 } ··· 369 // Parse the records first 370 case json.parse(body, decoder) { 371 Error(err) -> { 372 + logging.log( 373 + logging.Error, 374 + "[backfill] Failed to parse records: " <> string.inspect(err), 375 + ) 376 + logging.log( 377 + logging.Error, 378 + "[backfill] Response body snippet: " <> string.slice(body, 0, 200), 379 + ) 380 Error("Failed to parse listRecords response") 381 } 382 Ok(record_tuples) -> { ··· 526 |> list.flat_map(fn(repo) { 527 case list.find(atp_data, fn(data) { data.did == repo }) { 528 Error(_) -> { 529 + logging.log( 530 + logging.Error, 531 + "[backfill] No ATP data found for repo: " <> repo, 532 + ) 533 [] 534 } 535 Ok(data) -> { ··· 603 Error(err) -> { 604 logging.log( 605 logging.Error, 606 + "[backfill] Failed to upsert actor " 607 + <> data.did 608 + <> ": " 609 + <> string.inspect(err), 610 ) 611 } 612 } ··· 657 index_records(records, db) 658 }) 659 660 + logging.log(logging.Info, "[backfill] Completed sync for " <> did) 661 } 662 Error(err) -> { 663 logging.log( ··· 718 logging.log( 719 logging.Info, 720 "[backfill] Found " 721 + <> string.inspect(list.length(new_acc)) 722 + <> " total repositories for collection \"" 723 + <> collection 724 + <> "\"", 725 ) 726 Ok(new_acc) 727 } ··· 793 794 case collections { 795 [] -> { 796 + logging.log( 797 + logging.Error, 798 + "[backfill] No collections specified for backfill", 799 + ) 800 Nil 801 } 802 _ -> { 803 logging.log( 804 logging.Info, 805 "[backfill] Processing " 806 + <> string.inspect(list.length(collections)) 807 + <> " collections: " 808 + <> string.join(collections, ", "), 809 ) 810 811 run_backfill(repos, collections, external_collections, config, conn) ··· 820 config: BackfillConfig, 821 conn: sqlight.Connection, 822 ) -> Nil { 823 case external_collections { 824 [] -> Nil 825 _ -> 826 logging.log( 827 logging.Info, 828 "[backfill] Including " 829 + <> string.inspect(list.length(external_collections)) 830 + <> " external collections: " 831 + <> string.join(external_collections, ", "), 832 ) 833 } 834 ··· 836 let all_repos = case repos { 837 [] -> { 838 // Fetch repos for all collections from the relay 839 + logging.log( 840 + logging.Info, 841 + "[backfill] Fetching repositories for collections...", 842 + ) 843 let fetched_repos = 844 collections 845 |> list.filter_map(fn(collection) { ··· 857 logging.log( 858 logging.Info, 859 "[backfill] Processing " 860 + <> string.inspect(list.length(fetched_repos)) 861 + <> " unique repositories", 862 ) 863 fetched_repos 864 } ··· 866 logging.log( 867 logging.Info, 868 "[backfill] Using " 869 + <> string.inspect(list.length(provided_repos)) 870 + <> " provided repositories", 871 ) 872 provided_repos 873 } ··· 879 logging.log( 880 logging.Info, 881 "[backfill] Resolved ATP data for " 882 + <> string.inspect(list.length(atp_data)) 883 + <> "/" 884 + <> string.inspect(list.length(all_repos)) 885 + <> " repositories", 886 ) 887 888 // Get all records for all repos and collections (main collections only) 889 + logging.log( 890 + logging.Info, 891 + "[backfill] Fetching records for repositories and collections...", 892 + ) 893 let main_records = 894 get_records_for_repos(all_repos, collections, atp_data, config) 895 ··· 903 let all_records = list.append(main_records, external_records) 904 logging.log( 905 logging.Info, 906 + "[backfill] Fetched " 907 + <> string.inspect(list.length(all_records)) 908 + <> " total records", 909 ) 910 911 // Index actors (if enabled in config) ··· 915 index_actors(atp_data, conn) 916 logging.log( 917 logging.Info, 918 + "[backfill] Indexed " 919 + <> string.inspect(list.length(atp_data)) 920 + <> " actors", 921 ) 922 } 923 + False -> 924 + logging.log( 925 + logging.Info, 926 + "[backfill] Skipping actor indexing (disabled in config)", 927 + ) 928 } 929 930 // Index records 931 logging.log( 932 logging.Info, 933 + "[backfill] Indexing " 934 + <> string.inspect(list.length(all_records)) 935 + <> " records...", 936 ) 937 index_records(all_records, conn) 938 logging.log(logging.Info, "[backfill] Backfill complete!")
+5 -1
server/src/components/backfill_button.gleam
··· 23 backfill_state_subject: process.Subject(backfill_state.Message), 24 config_subject: process.Subject(config.Message), 25 ) { 26 - lustre.application(init(db, backfill_state_subject, config_subject, _), update, view) 27 } 28 29 // MODEL
··· 23 backfill_state_subject: process.Subject(backfill_state.Message), 24 config_subject: process.Subject(config.Message), 25 ) { 26 + lustre.application( 27 + init(db, backfill_state_subject, config_subject, _), 28 + update, 29 + view, 30 + ) 31 } 32 33 // MODEL
+3 -4
server/src/components/button.gleam
··· 25 26 /// Render a link styled as a button 27 pub fn link(href href: String, text text: String) -> Element(msg) { 28 - html.a( 29 - [attribute.href(href), attribute.class(button_classes)], 30 - [html.text(text)], 31 - ) 32 }
··· 25 26 /// Render a link styled as a button 27 pub fn link(href href: String, text text: String) -> Element(msg) { 28 + html.a([attribute.href(href), attribute.class(button_classes)], [ 29 + html.text(text), 30 + ]) 31 }
+4 -1
server/src/components/collection_table.gleam
··· 21 [ 22 html.table([attribute.class("min-w-full divide-y divide-zinc-800")], [ 23 render_header(), 24 - html.tbody([attribute.class("bg-zinc-900 divide-y divide-zinc-800")], rows), 25 ]), 26 ], 27 )
··· 21 [ 22 html.table([attribute.class("min-w-full divide-y divide-zinc-800")], [ 23 render_header(), 24 + html.tbody( 25 + [attribute.class("bg-zinc-900 divide-y divide-zinc-800")], 26 + rows, 27 + ), 28 ]), 29 ], 30 )
+17 -22
server/src/components/input.gleam
··· 55 False -> element.none() 56 }, 57 ]), 58 - html.input( 59 - [ 60 - attribute.type_("text"), 61 - attribute.name(name), 62 - attribute.id(name), 63 - attribute.value(value), 64 - attribute.placeholder(placeholder), 65 - attribute.class(input_classes), 66 - ..required_attr 67 - ], 68 - ), 69 ]) 70 } 71 ··· 90 False -> element.none() 91 }, 92 ]), 93 - html.input( 94 - [ 95 - attribute.type_("file"), 96 - attribute.name(name), 97 - attribute.id(name), 98 - attribute.attribute("accept", accept), 99 - attribute.class(input_classes), 100 - ..required_attr 101 - ], 102 - ), 103 ]) 104 } 105 -
··· 55 False -> element.none() 56 }, 57 ]), 58 + html.input([ 59 + attribute.type_("text"), 60 + attribute.name(name), 61 + attribute.id(name), 62 + attribute.value(value), 63 + attribute.placeholder(placeholder), 64 + attribute.class(input_classes), 65 + ..required_attr 66 + ]), 67 ]) 68 } 69 ··· 88 False -> element.none() 89 }, 90 ]), 91 + html.input([ 92 + attribute.type_("file"), 93 + attribute.name(name), 94 + attribute.id(name), 95 + attribute.attribute("accept", accept), 96 + attribute.class(input_classes), 97 + ..required_attr 98 + ]), 99 ]) 100 }
+23 -12
server/src/components/layout.gleam
··· 13 ) -> Element(msg) { 14 html.html([attribute.class("h-full")], [ 15 head(title), 16 - html.body([attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], [ 17 - html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], [ 18 - render_header(current_user, domain_authority), 19 - ..content 20 - ]), 21 - ]), 22 ]) 23 } 24 25 /// Renders a complete HTML page with the given title and content 26 - pub fn page(title title: String, content content: List(Element(msg))) -> Element(msg) { 27 html.html([attribute.class("h-full")], [ 28 head(title), 29 body(content), ··· 77 78 /// Renders the HTML body with a max-width container and navigation 79 fn body(content: List(Element(msg))) -> Element(msg) { 80 - html.body([attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], [ 81 - nav_header(), 82 - html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], content), 83 - ]) 84 } 85 86 /// Renders the navigation header ··· 91 html.a( 92 [ 93 attribute.href("/"), 94 - attribute.class("text-zinc-300 hover:text-zinc-100 transition-colors"), 95 ], 96 [element.text("quickslice")], 97 ),
··· 13 ) -> Element(msg) { 14 html.html([attribute.class("h-full")], [ 15 head(title), 16 + html.body( 17 + [attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], 18 + [ 19 + html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], [ 20 + render_header(current_user, domain_authority), 21 + ..content 22 + ]), 23 + ], 24 + ), 25 ]) 26 } 27 28 /// Renders a complete HTML page with the given title and content 29 + pub fn page( 30 + title title: String, 31 + content content: List(Element(msg)), 32 + ) -> Element(msg) { 33 html.html([attribute.class("h-full")], [ 34 head(title), 35 body(content), ··· 83 84 /// Renders the HTML body with a max-width container and navigation 85 fn body(content: List(Element(msg))) -> Element(msg) { 86 + html.body( 87 + [attribute.class("bg-zinc-950 text-zinc-300 font-mono min-h-screen")], 88 + [ 89 + nav_header(), 90 + html.div([attribute.class("max-w-4xl mx-auto px-6 py-12")], content), 91 + ], 92 + ) 93 } 94 95 /// Renders the navigation header ··· 100 html.a( 101 [ 102 attribute.href("/"), 103 + attribute.class( 104 + "text-zinc-300 hover:text-zinc-100 transition-colors", 105 + ), 106 ], 107 [element.text("quickslice")], 108 ),
+44 -47
server/src/components/logo.gleam
··· 12 ], 13 [ 14 // Define gradients 15 - svg.defs( 16 - [], 17 - [ 18 - svg.linear_gradient( 19 - [ 20 - attribute.id("board1"), 21 - attribute.attribute("x1", "0%"), 22 - attribute.attribute("y1", "0%"), 23 - attribute.attribute("x2", "100%"), 24 - attribute.attribute("y2", "100%"), 25 - ], 26 - [ 27 - svg.stop([ 28 - attribute.attribute("offset", "0%"), 29 - attribute.attribute("stop-color", "#FF6347"), 30 - attribute.attribute("stop-opacity", "1"), 31 - ]), 32 - svg.stop([ 33 - attribute.attribute("offset", "100%"), 34 - attribute.attribute("stop-color", "#FF4500"), 35 - attribute.attribute("stop-opacity", "1"), 36 - ]), 37 - ], 38 - ), 39 - svg.linear_gradient( 40 - [ 41 - attribute.id("board2"), 42 - attribute.attribute("x1", "0%"), 43 - attribute.attribute("y1", "0%"), 44 - attribute.attribute("x2", "100%"), 45 - attribute.attribute("y2", "100%"), 46 - ], 47 - [ 48 - svg.stop([ 49 - attribute.attribute("offset", "0%"), 50 - attribute.attribute("stop-color", "#00CED1"), 51 - attribute.attribute("stop-opacity", "1"), 52 - ]), 53 - svg.stop([ 54 - attribute.attribute("offset", "100%"), 55 - attribute.attribute("stop-color", "#4682B4"), 56 - attribute.attribute("stop-opacity", "1"), 57 - ]), 58 - ], 59 - ), 60 - ], 61 - ), 62 // Surfboard/skateboard deck shapes stacked 63 svg.g([attribute.attribute("transform", "translate(30, 30)")], [ 64 // Top board slice
··· 12 ], 13 [ 14 // Define gradients 15 + svg.defs([], [ 16 + svg.linear_gradient( 17 + [ 18 + attribute.id("board1"), 19 + attribute.attribute("x1", "0%"), 20 + attribute.attribute("y1", "0%"), 21 + attribute.attribute("x2", "100%"), 22 + attribute.attribute("y2", "100%"), 23 + ], 24 + [ 25 + svg.stop([ 26 + attribute.attribute("offset", "0%"), 27 + attribute.attribute("stop-color", "#FF6347"), 28 + attribute.attribute("stop-opacity", "1"), 29 + ]), 30 + svg.stop([ 31 + attribute.attribute("offset", "100%"), 32 + attribute.attribute("stop-color", "#FF4500"), 33 + attribute.attribute("stop-opacity", "1"), 34 + ]), 35 + ], 36 + ), 37 + svg.linear_gradient( 38 + [ 39 + attribute.id("board2"), 40 + attribute.attribute("x1", "0%"), 41 + attribute.attribute("y1", "0%"), 42 + attribute.attribute("x2", "100%"), 43 + attribute.attribute("y2", "100%"), 44 + ], 45 + [ 46 + svg.stop([ 47 + attribute.attribute("offset", "0%"), 48 + attribute.attribute("stop-color", "#00CED1"), 49 + attribute.attribute("stop-opacity", "1"), 50 + ]), 51 + svg.stop([ 52 + attribute.attribute("offset", "100%"), 53 + attribute.attribute("stop-color", "#4682B4"), 54 + attribute.attribute("stop-opacity", "1"), 55 + ]), 56 + ], 57 + ), 58 + ]), 59 // Surfboard/skateboard deck shapes stacked 60 svg.g([attribute.attribute("transform", "translate(30, 30)")], [ 61 // Top board slice
+48 -41
server/src/components/sparkline.gleam
··· 43 |> list.index_map(fn(point, index) { 44 let x = case data_length { 45 1 -> int.to_float(width) /. 2.0 46 - _ -> int.to_float(index) /. int.to_float(data_length - 1) *. int.to_float(width) 47 } 48 let y_normalized = int.to_float(point.count - min) /. range_float 49 let y = int.to_float(height) -. y_normalized *. max_height ··· 54 55 // Generate area path for gradient fill 56 let area_path = 57 - "M 0," <> int.to_string(height) 58 - <> " L " <> points_string 59 - <> " L " <> int.to_string(width) <> "," <> int.to_string(height) 60 <> " Z" 61 62 // Create SVG element ··· 66 attribute.attribute("width", int.to_string(width)), 67 attribute.attribute("height", int.to_string(height)), 68 attribute.class("w-full"), 69 - attribute.attribute("viewBox", "0 0 " <> int.to_string(width) <> " " <> int.to_string(height)), 70 attribute.attribute("preserveAspectRatio", "none"), 71 ], 72 [ 73 // Define gradient 74 - element.element( 75 - "defs", 76 - [], 77 - [ 78 - element.element( 79 - "linearGradient", 80 - [ 81 - attribute.id("sparklineGradient"), 82 - attribute.attribute("x1", "0%"), 83 - attribute.attribute("y1", "0%"), 84 - attribute.attribute("x2", "0%"), 85 - attribute.attribute("y2", "100%"), 86 - ], 87 - [ 88 - element.element( 89 - "stop", 90 - [ 91 - attribute.attribute("offset", "0%"), 92 - attribute.attribute("stop-color", "#22d3ee"), 93 - attribute.attribute("stop-opacity", "0.5"), 94 - ], 95 - [], 96 - ), 97 - element.element( 98 - "stop", 99 - [ 100 - attribute.attribute("offset", "100%"), 101 - attribute.attribute("stop-color", "#22d3ee"), 102 - attribute.attribute("stop-opacity", "0.1"), 103 - ], 104 - [], 105 - ), 106 - ], 107 - ), 108 - ], 109 - ), 110 // Area fill 111 element.element( 112 "path",
··· 43 |> list.index_map(fn(point, index) { 44 let x = case data_length { 45 1 -> int.to_float(width) /. 2.0 46 + _ -> 47 + int.to_float(index) 48 + /. int.to_float(data_length - 1) 49 + *. int.to_float(width) 50 } 51 let y_normalized = int.to_float(point.count - min) /. range_float 52 let y = int.to_float(height) -. y_normalized *. max_height ··· 57 58 // Generate area path for gradient fill 59 let area_path = 60 + "M 0," 61 + <> int.to_string(height) 62 + <> " L " 63 + <> points_string 64 + <> " L " 65 + <> int.to_string(width) 66 + <> "," 67 + <> int.to_string(height) 68 <> " Z" 69 70 // Create SVG element ··· 74 attribute.attribute("width", int.to_string(width)), 75 attribute.attribute("height", int.to_string(height)), 76 attribute.class("w-full"), 77 + attribute.attribute( 78 + "viewBox", 79 + "0 0 " <> int.to_string(width) <> " " <> int.to_string(height), 80 + ), 81 attribute.attribute("preserveAspectRatio", "none"), 82 ], 83 [ 84 // Define gradient 85 + element.element("defs", [], [ 86 + element.element( 87 + "linearGradient", 88 + [ 89 + attribute.id("sparklineGradient"), 90 + attribute.attribute("x1", "0%"), 91 + attribute.attribute("y1", "0%"), 92 + attribute.attribute("x2", "0%"), 93 + attribute.attribute("y2", "100%"), 94 + ], 95 + [ 96 + element.element( 97 + "stop", 98 + [ 99 + attribute.attribute("offset", "0%"), 100 + attribute.attribute("stop-color", "#22d3ee"), 101 + attribute.attribute("stop-opacity", "0.5"), 102 + ], 103 + [], 104 + ), 105 + element.element( 106 + "stop", 107 + [ 108 + attribute.attribute("offset", "100%"), 109 + attribute.attribute("stop-color", "#22d3ee"), 110 + attribute.attribute("stop-opacity", "0.1"), 111 + ], 112 + [], 113 + ), 114 + ], 115 + ), 116 + ]), 117 // Area fill 118 element.element( 119 "path",
+11 -4
server/src/components/stats_card.gleam
··· 16 html.div( 17 [ 18 attribute.class( 19 - "bg-zinc-900 " <> bg_class <> " rounded-lg p-6 border " <> border_class <> " shadow-sm", 20 ), 21 ], 22 [ 23 - html.div([attribute.class("text-4xl font-bold " <> text_class <> " mb-2")], [ 24 - element.text(int.to_string(count)), 25 - ]), 26 html.div([attribute.class("text-zinc-400")], [element.text(description)]), 27 ], 28 )
··· 16 html.div( 17 [ 18 attribute.class( 19 + "bg-zinc-900 " 20 + <> bg_class 21 + <> " rounded-lg p-6 border " 22 + <> border_class 23 + <> " shadow-sm", 24 ), 25 ], 26 [ 27 + html.div( 28 + [attribute.class("text-4xl font-bold " <> text_class <> " mb-2")], 29 + [ 30 + element.text(int.to_string(count)), 31 + ], 32 + ), 33 html.div([attribute.class("text-zinc-400")], [element.text(description)]), 34 ], 35 )
+6 -12
server/src/config.gleam
··· 42 } 43 44 /// Start the config cache actor 45 - pub fn start(db: sqlight.Connection) -> Result(process.Subject(Message), actor.StartError) { 46 // Load initial domain authority from database 47 let initial_state = case database.get_config(db, "domain_authority") { 48 Ok(value) -> ConfigCache(domain_authority: Some(value)) ··· 67 } 68 69 /// Get the current domain authority from cache 70 - pub fn get_domain_authority( 71 - config: process.Subject(Message), 72 - ) -> Option(String) { 73 actor.call(config, waiting: 100, sending: GetDomainAuthority) 74 } 75 ··· 84 Ok(_) -> { 85 // Update cache 86 actor.call(config, waiting: 100, sending: SetDomainAuthority(value, _)) 87 - logging.log( 88 - logging.Info, 89 - "[config] Updated domain_authority: " <> value, 90 - ) 91 Ok(Nil) 92 } 93 Error(err) -> Error(err) ··· 95 } 96 97 /// Reload config from database (useful after external updates) 98 - pub fn reload( 99 - config: process.Subject(Message), 100 - db: sqlight.Connection, 101 - ) -> Nil { 102 actor.call(config, waiting: 100, sending: Reload(db, _)) 103 }
··· 42 } 43 44 /// Start the config cache actor 45 + pub fn start( 46 + db: sqlight.Connection, 47 + ) -> Result(process.Subject(Message), actor.StartError) { 48 // Load initial domain authority from database 49 let initial_state = case database.get_config(db, "domain_authority") { 50 Ok(value) -> ConfigCache(domain_authority: Some(value)) ··· 69 } 70 71 /// Get the current domain authority from cache 72 + pub fn get_domain_authority(config: process.Subject(Message)) -> Option(String) { 73 actor.call(config, waiting: 100, sending: GetDomainAuthority) 74 } 75 ··· 84 Ok(_) -> { 85 // Update cache 86 actor.call(config, waiting: 100, sending: SetDomainAuthority(value, _)) 87 + logging.log(logging.Info, "[config] Updated domain_authority: " <> value) 88 Ok(Nil) 89 } 90 Error(err) -> Error(err) ··· 92 } 93 94 /// Reload config from database (useful after external updates) 95 + pub fn reload(config: process.Subject(Message), db: sqlight.Connection) -> Nil { 96 actor.call(config, waiting: 100, sending: Reload(db, _)) 97 }
+18 -9
server/src/database.gleam
··· 325 decode.success(value) 326 } 327 328 - case sqlight.query(sql, on: conn, with: [sqlight.text(key)], expecting: decoder) { 329 Ok([value, ..]) -> Ok(value) 330 - Ok([]) -> Error(sqlight.SqlightError(sqlight.ConstraintForeignkey, "Config key not found", -1)) 331 Error(err) -> Error(err) 332 } 333 } ··· 376 pub fn get_oauth_credentials( 377 conn: sqlight.Connection, 378 ) -> Result(Option(#(String, String, String)), sqlight.Error) { 379 - case get_config(conn, "oauth_client_id"), get_config(conn, "oauth_client_secret") { 380 Ok(client_id), Ok(client_secret) -> { 381 let redirect_uri = case get_config(conn, "oauth_redirect_uri") { 382 Ok(uri) -> uri ··· 436 } 437 438 /// Deletes all actors from the database 439 - pub fn delete_all_actors( 440 - conn: sqlight.Connection, 441 - ) -> Result(Nil, sqlight.Error) { 442 let sql = "DELETE FROM actor" 443 444 sqlight.exec(sql, conn) ··· 979 duration_hours: Int, 980 ) -> Result(List(ActivityPoint), sqlight.Error) { 981 // SQLite datetime calculation for cutoff time 982 - let sql = 983 - " 984 WITH RECURSIVE time_series AS ( 985 SELECT datetime('now', '-" <> int.to_string(duration_hours) <> " hours') AS bucket 986 UNION ALL ··· 995 LEFT JOIN record r ON 996 datetime(r.indexed_at) >= datetime(ts.bucket) 997 AND datetime(r.indexed_at) < datetime(ts.bucket, '+1 hour') 998 - AND datetime(r.indexed_at) >= datetime('now', '-" <> int.to_string(duration_hours) <> " hours') 999 GROUP BY ts.bucket 1000 ORDER BY ts.bucket ASC 1001 "
··· 325 decode.success(value) 326 } 327 328 + case 329 + sqlight.query(sql, on: conn, with: [sqlight.text(key)], expecting: decoder) 330 + { 331 Ok([value, ..]) -> Ok(value) 332 + Ok([]) -> 333 + Error(sqlight.SqlightError( 334 + sqlight.ConstraintForeignkey, 335 + "Config key not found", 336 + -1, 337 + )) 338 Error(err) -> Error(err) 339 } 340 } ··· 383 pub fn get_oauth_credentials( 384 conn: sqlight.Connection, 385 ) -> Result(Option(#(String, String, String)), sqlight.Error) { 386 + case 387 + get_config(conn, "oauth_client_id"), 388 + get_config(conn, "oauth_client_secret") 389 + { 390 Ok(client_id), Ok(client_secret) -> { 391 let redirect_uri = case get_config(conn, "oauth_redirect_uri") { 392 Ok(uri) -> uri ··· 446 } 447 448 /// Deletes all actors from the database 449 + pub fn delete_all_actors(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 450 let sql = "DELETE FROM actor" 451 452 sqlight.exec(sql, conn) ··· 987 duration_hours: Int, 988 ) -> Result(List(ActivityPoint), sqlight.Error) { 989 // SQLite datetime calculation for cutoff time 990 + let sql = " 991 WITH RECURSIVE time_series AS ( 992 SELECT datetime('now', '-" <> int.to_string(duration_hours) <> " hours') AS bucket 993 UNION ALL ··· 1002 LEFT JOIN record r ON 1003 datetime(r.indexed_at) >= datetime(ts.bucket) 1004 AND datetime(r.indexed_at) < datetime(ts.bucket, '+1 hour') 1005 + AND datetime(r.indexed_at) >= datetime('now', '-" <> int.to_string( 1006 + duration_hours, 1007 + ) <> " hours') 1008 GROUP BY ts.bucket 1009 ORDER BY ts.bucket ASC 1010 "
+97 -74
server/src/event_handler.gleam
··· 3 import database 4 import gleam/dynamic.{type Dynamic} 5 import gleam/dynamic/decode 6 - import logging 7 import gleam/list 8 import gleam/option 9 import gleam/string 10 import goose 11 import lexicon 12 import pubsub 13 import sqlight 14 ··· 34 case decode.run(binary, decode.string) { 35 Ok(str) -> str 36 Error(_) -> { 37 - logging.log(logging.Warning, "[jetstream] Failed to convert iolist to string") 38 string.inspect(iolist) 39 } 40 } ··· 81 // Check if record already exists BEFORE inserting to determine operation type 82 let existing_record = database.get_record(db, uri) 83 let is_create = case existing_record { 84 - Ok([]) -> True // Empty list means record doesn't exist 85 - Ok(_) -> False // Non-empty list means record exists 86 Error(_) -> { 87 // Database error - log it and treat as update to be safe 88 logging.log( ··· 115 // Validation passed, insert record 116 case 117 database.insert_record( 118 - db, 119 - uri, 120 - cid_value, 121 - did, 122 - commit.collection, 123 - json_string, 124 - ) 125 - { 126 - Ok(_) -> { 127 - logging.log( 128 - logging.Info, 129 - "[jetstream] " 130 - <> case is_create { 131 - True -> "create" 132 - False -> "update" 133 - } 134 - <> " " 135 - <> commit.collection 136 - <> " (" 137 - <> commit.rkey 138 - <> ") " 139 - <> did, 140 - ) 141 142 - // Publish event to PubSub for GraphQL subscriptions 143 - let operation = case is_create { 144 - True -> pubsub.Create 145 - False -> pubsub.Update 146 - } 147 148 - // Convert event timestamp from microseconds to ISO8601 149 - let indexed_at = microseconds_to_iso8601(time_us) 150 151 - let event = 152 - pubsub.RecordEvent( 153 - uri: uri, 154 - cid: cid_value, 155 - did: did, 156 - collection: commit.collection, 157 - value: json_string, 158 - indexed_at: indexed_at, 159 - operation: operation, 160 - ) 161 162 - pubsub.publish(event) 163 - } 164 - Error(err) -> { 165 - logging.log( 166 - logging.Error, 167 - "[jetstream] Failed to insert record " 168 - <> uri 169 - <> ": " 170 - <> string.inspect(err), 171 - ) 172 - } 173 - } 174 } 175 Error(actor_err) -> { 176 logging.log( 177 logging.Error, 178 "[jetstream] Failed to validate/create actor for " 179 - <> uri 180 - <> ": " 181 - <> actor_err, 182 ) 183 } 184 } ··· 187 logging.log( 188 logging.Warning, 189 "[jetstream] Validation failed for " 190 - <> uri 191 - <> ": " 192 - <> lexicon.describe_error(validation_error), 193 ) 194 } 195 } ··· 198 logging.log( 199 logging.Error, 200 "[jetstream] Failed to fetch lexicons for validation: " 201 - <> string.inspect(db_err), 202 ) 203 } 204 } ··· 207 logging.log( 208 logging.Warning, 209 "[jetstream] " 210 - <> commit.operation 211 - <> " event missing record or cid for " 212 - <> uri, 213 ) 214 } 215 } ··· 217 "delete" -> { 218 logging.log( 219 logging.Info, 220 - "[jetstream] delete " <> commit.collection <> " (" <> commit.rkey <> ") " <> did, 221 ) 222 223 case database.delete_record(db, uri) { ··· 240 pubsub.publish(event) 241 } 242 Error(err) -> { 243 - logging.log(logging.Error, "[jetstream] Failed to delete: " <> string.inspect(err)) 244 } 245 } 246 } 247 _ -> { 248 - logging.log(logging.Warning, "[jetstream] Unknown operation: " <> commit.operation) 249 } 250 } 251 } ··· 259 Ok(_) -> { 260 logging.log( 261 logging.Info, 262 - "[jetstream] identity update: " <> identity.handle <> " (" <> identity.did <> ")", 263 ) 264 } 265 Error(err) -> { 266 logging.log( 267 logging.Error, 268 "[jetstream] Failed to upsert actor " 269 - <> identity.did 270 - <> ": " 271 - <> string.inspect(err), 272 ) 273 } 274 } ··· 284 True -> "active" 285 False -> "inactive" 286 } 287 - logging.log(logging.Info, "[jetstream] account " <> status <> ": " <> account.did) 288 }
··· 3 import database 4 import gleam/dynamic.{type Dynamic} 5 import gleam/dynamic/decode 6 import gleam/list 7 import gleam/option 8 import gleam/string 9 import goose 10 import lexicon 11 + import logging 12 import pubsub 13 import sqlight 14 ··· 34 case decode.run(binary, decode.string) { 35 Ok(str) -> str 36 Error(_) -> { 37 + logging.log( 38 + logging.Warning, 39 + "[jetstream] Failed to convert iolist to string", 40 + ) 41 string.inspect(iolist) 42 } 43 } ··· 84 // Check if record already exists BEFORE inserting to determine operation type 85 let existing_record = database.get_record(db, uri) 86 let is_create = case existing_record { 87 + Ok([]) -> True 88 + // Empty list means record doesn't exist 89 + Ok(_) -> False 90 + // Non-empty list means record exists 91 Error(_) -> { 92 // Database error - log it and treat as update to be safe 93 logging.log( ··· 120 // Validation passed, insert record 121 case 122 database.insert_record( 123 + db, 124 + uri, 125 + cid_value, 126 + did, 127 + commit.collection, 128 + json_string, 129 + ) 130 + { 131 + Ok(_) -> { 132 + logging.log( 133 + logging.Info, 134 + "[jetstream] " 135 + <> case is_create { 136 + True -> "create" 137 + False -> "update" 138 + } 139 + <> " " 140 + <> commit.collection 141 + <> " (" 142 + <> commit.rkey 143 + <> ") " 144 + <> did, 145 + ) 146 147 + // Publish event to PubSub for GraphQL subscriptions 148 + let operation = case is_create { 149 + True -> pubsub.Create 150 + False -> pubsub.Update 151 + } 152 153 + // Convert event timestamp from microseconds to ISO8601 154 + let indexed_at = microseconds_to_iso8601(time_us) 155 156 + let event = 157 + pubsub.RecordEvent( 158 + uri: uri, 159 + cid: cid_value, 160 + did: did, 161 + collection: commit.collection, 162 + value: json_string, 163 + indexed_at: indexed_at, 164 + operation: operation, 165 + ) 166 167 + pubsub.publish(event) 168 + } 169 + Error(err) -> { 170 + logging.log( 171 + logging.Error, 172 + "[jetstream] Failed to insert record " 173 + <> uri 174 + <> ": " 175 + <> string.inspect(err), 176 + ) 177 + } 178 + } 179 } 180 Error(actor_err) -> { 181 logging.log( 182 logging.Error, 183 "[jetstream] Failed to validate/create actor for " 184 + <> uri 185 + <> ": " 186 + <> actor_err, 187 ) 188 } 189 } ··· 192 logging.log( 193 logging.Warning, 194 "[jetstream] Validation failed for " 195 + <> uri 196 + <> ": " 197 + <> lexicon.describe_error(validation_error), 198 ) 199 } 200 } ··· 203 logging.log( 204 logging.Error, 205 "[jetstream] Failed to fetch lexicons for validation: " 206 + <> string.inspect(db_err), 207 ) 208 } 209 } ··· 212 logging.log( 213 logging.Warning, 214 "[jetstream] " 215 + <> commit.operation 216 + <> " event missing record or cid for " 217 + <> uri, 218 ) 219 } 220 } ··· 222 "delete" -> { 223 logging.log( 224 logging.Info, 225 + "[jetstream] delete " 226 + <> commit.collection 227 + <> " (" 228 + <> commit.rkey 229 + <> ") " 230 + <> did, 231 ) 232 233 case database.delete_record(db, uri) { ··· 250 pubsub.publish(event) 251 } 252 Error(err) -> { 253 + logging.log( 254 + logging.Error, 255 + "[jetstream] Failed to delete: " <> string.inspect(err), 256 + ) 257 } 258 } 259 } 260 _ -> { 261 + logging.log( 262 + logging.Warning, 263 + "[jetstream] Unknown operation: " <> commit.operation, 264 + ) 265 } 266 } 267 } ··· 275 Ok(_) -> { 276 logging.log( 277 logging.Info, 278 + "[jetstream] identity update: " 279 + <> identity.handle 280 + <> " (" 281 + <> identity.did 282 + <> ")", 283 ) 284 } 285 Error(err) -> { 286 logging.log( 287 logging.Error, 288 "[jetstream] Failed to upsert actor " 289 + <> identity.did 290 + <> ": " 291 + <> string.inspect(err), 292 ) 293 } 294 } ··· 304 True -> "active" 305 False -> "inactive" 306 } 307 + logging.log( 308 + logging.Info, 309 + "[jetstream] account " <> status <> ": " <> account.did, 310 + ) 311 }
+11 -11
server/src/graphql_gleam.gleam
··· 14 import gleam/option 15 import gleam/result 16 import gleam/string 17 - import graphql/executor 18 - import graphql/schema 19 - import graphql/value 20 import lexicon_graphql/dataloader 21 import lexicon_graphql/db_schema_builder 22 import lexicon_graphql/lexicon_parser 23 import mutation_resolvers 24 import sqlight 25 import where_converter 26 27 /// Build a GraphQL schema from database lexicons ··· 347 let external_collection_ids = 348 parsed_lexicons 349 |> list.filter_map(fn(lex) { 350 - case backfill.nsid_matches_domain_authority(lex.id, domain_authority) { 351 - True -> Error(Nil) // Local collection, skip 352 - False -> Ok(lex.id) // External collection, include 353 } 354 }) 355 ··· 439 let ctx = schema.context_with_variables(ctx_data, variables_dict) 440 441 // Execute the query 442 - use response <- result.try(executor.execute( 443 - query_string, 444 - graphql_schema, 445 - ctx, 446 - )) 447 448 // Format the response as JSON 449 Ok(format_response(response))
··· 14 import gleam/option 15 import gleam/result 16 import gleam/string 17 import lexicon_graphql/dataloader 18 import lexicon_graphql/db_schema_builder 19 import lexicon_graphql/lexicon_parser 20 import mutation_resolvers 21 import sqlight 22 + import swell/executor 23 + import swell/schema 24 + import swell/value 25 import where_converter 26 27 /// Build a GraphQL schema from database lexicons ··· 347 let external_collection_ids = 348 parsed_lexicons 349 |> list.filter_map(fn(lex) { 350 + case 351 + backfill.nsid_matches_domain_authority(lex.id, domain_authority) 352 + { 353 + True -> Error(Nil) 354 + // Local collection, skip 355 + False -> Ok(lex.id) 356 + // External collection, include 357 } 358 }) 359 ··· 443 let ctx = schema.context_with_variables(ctx_data, variables_dict) 444 445 // Execute the query 446 + use response <- result.try(executor.execute(query_string, graphql_schema, ctx)) 447 448 // Format the response as JSON 449 Ok(format_response(response))
+6 -2
server/src/graphql_ws.gleam
··· 41 case message_type { 42 "connection_init" -> { 43 // Try to extract payload, but it's optional 44 - let payload = extract_string_payload(json_str) |> option.unwrap(dict.new()) 45 Ok(ConnectionInit(payload)) 46 } 47 ··· 137 // Helper to extract payload field as dict of strings 138 fn extract_string_payload(json_str: String) -> Option(Dict(String, String)) { 139 let decoder = { 140 - use payload <- decode.field("payload", decode.dict(decode.string, decode.string)) 141 decode.success(payload) 142 } 143
··· 41 case message_type { 42 "connection_init" -> { 43 // Try to extract payload, but it's optional 44 + let payload = 45 + extract_string_payload(json_str) |> option.unwrap(dict.new()) 46 Ok(ConnectionInit(payload)) 47 } 48 ··· 138 // Helper to extract payload field as dict of strings 139 fn extract_string_payload(json_str: String) -> Option(Dict(String, String)) { 140 let decoder = { 141 + use payload <- decode.field( 142 + "payload", 143 + decode.dict(decode.string, decode.string), 144 + ) 145 decode.success(payload) 146 } 147
+49 -35
server/src/graphql_ws_handler.gleam
··· 10 import gleam/option.{None, Some} 11 import gleam/result 12 import gleam/string 13 - import graphql/executor 14 - import graphql/parser 15 - import graphql/schema 16 - import graphql/value 17 import graphql_gleam 18 import graphql_ws 19 import logging 20 - import mist.{type Connection, type ResponseData, type WebsocketConnection, type WebsocketMessage} 21 import pubsub 22 import sqlight 23 import websocket_ffi 24 25 /// Configuration constants ··· 97 |> string.split(".") 98 |> list.index_map(fn(part, index) { 99 case index { 100 - 0 -> part // Keep first segment lowercase 101 _ -> { 102 // Capitalize first letter of subsequent segments 103 case string.pop_grapheme(part) { ··· 149 } 150 } 151 152 - 153 /// Subscription metadata 154 pub type SubscriptionInfo { 155 SubscriptionInfo( ··· 188 logging.log(logging.Info, "[websocket] Client connected") 189 190 // Build GraphQL schema for subscriptions 191 - let graphql_schema = case graphql_gleam.build_schema_from_db( 192 - db, 193 - auth_base_url, 194 - plc_url, 195 - domain_authority, 196 - ) { 197 Ok(schema) -> schema 198 Error(err) -> { 199 // Schema build failed - this is a critical error for subscriptions ··· 261 handle_text_message(state, conn, text) 262 } 263 mist.Binary(_) -> { 264 - logging.log(logging.Warning, "[websocket] Received binary message, ignoring") 265 mist.continue(state) 266 } 267 mist.Closed | mist.Shutdown -> { ··· 276 } 277 } 278 279 - 280 /// Handle text messages (GraphQL-WS protocol) 281 - fn handle_text_message( 282 - state: State, 283 - conn: WebsocketConnection, 284 - text: String, 285 - ) { 286 case graphql_ws.parse_message(text) { 287 Ok(graphql_ws.ConnectionInit(_payload)) -> { 288 // Send connection_ack ··· 356 357 logging.log( 358 logging.Info, 359 - "[websocket] Subscription started: " <> id <> " (field: " <> field_name <> ")", 360 ) 361 362 // Spawn an unlinked process to listen for PubSub events 363 - let listener_pid = process.spawn_unlinked(fn() { 364 - subscription_listener( 365 - state.subscription_subject, 366 - id, 367 - query, 368 - field_name, 369 - variables, 370 - state.db, 371 - state.schema, 372 - ) 373 - }) 374 375 // Increment global counter 376 let _ = increment_global_subscriptions() ··· 380 SubscriptionInfo(listener_pid, field_name, variables) 381 let new_subscriptions = 382 dict.insert(state.subscriptions, id, subscription_info) 383 - let new_state = State(..state, subscriptions: new_subscriptions) 384 385 mist.continue(new_state) 386 } ··· 404 let new_subscriptions = dict.delete(state.subscriptions, id) 405 let new_state = State(..state, subscriptions: new_subscriptions) 406 407 - logging.log(logging.Info, "[websocket] Subscription completed: " <> id) 408 409 mist.continue(new_state) 410 } ··· 481 case event_matches_subscription(event, subscription_field) { 482 True -> { 483 // Execute the GraphQL subscription query with the event data and variables 484 - case execute_subscription_query(query, variables, graphql_schema, event, db) { 485 Ok(result_json) -> { 486 // Send message to handler via Subject 487 process.send(
··· 10 import gleam/option.{None, Some} 11 import gleam/result 12 import gleam/string 13 import graphql_gleam 14 import graphql_ws 15 import logging 16 + import mist.{ 17 + type Connection, type ResponseData, type WebsocketConnection, 18 + type WebsocketMessage, 19 + } 20 import pubsub 21 import sqlight 22 + import swell/executor 23 + import swell/parser 24 + import swell/schema 25 + import swell/value 26 import websocket_ffi 27 28 /// Configuration constants ··· 100 |> string.split(".") 101 |> list.index_map(fn(part, index) { 102 case index { 103 + 0 -> part 104 + // Keep first segment lowercase 105 _ -> { 106 // Capitalize first letter of subsequent segments 107 case string.pop_grapheme(part) { ··· 153 } 154 } 155 156 /// Subscription metadata 157 pub type SubscriptionInfo { 158 SubscriptionInfo( ··· 191 logging.log(logging.Info, "[websocket] Client connected") 192 193 // Build GraphQL schema for subscriptions 194 + let graphql_schema = case 195 + graphql_gleam.build_schema_from_db( 196 + db, 197 + auth_base_url, 198 + plc_url, 199 + domain_authority, 200 + ) 201 + { 202 Ok(schema) -> schema 203 Error(err) -> { 204 // Schema build failed - this is a critical error for subscriptions ··· 266 handle_text_message(state, conn, text) 267 } 268 mist.Binary(_) -> { 269 + logging.log( 270 + logging.Warning, 271 + "[websocket] Received binary message, ignoring", 272 + ) 273 mist.continue(state) 274 } 275 mist.Closed | mist.Shutdown -> { ··· 284 } 285 } 286 287 /// Handle text messages (GraphQL-WS protocol) 288 + fn handle_text_message(state: State, conn: WebsocketConnection, text: String) { 289 case graphql_ws.parse_message(text) { 290 Ok(graphql_ws.ConnectionInit(_payload)) -> { 291 // Send connection_ack ··· 359 360 logging.log( 361 logging.Info, 362 + "[websocket] Subscription started: " 363 + <> id 364 + <> " (field: " 365 + <> field_name 366 + <> ")", 367 ) 368 369 // Spawn an unlinked process to listen for PubSub events 370 + let listener_pid = 371 + process.spawn_unlinked(fn() { 372 + subscription_listener( 373 + state.subscription_subject, 374 + id, 375 + query, 376 + field_name, 377 + variables, 378 + state.db, 379 + state.schema, 380 + ) 381 + }) 382 383 // Increment global counter 384 let _ = increment_global_subscriptions() ··· 388 SubscriptionInfo(listener_pid, field_name, variables) 389 let new_subscriptions = 390 dict.insert(state.subscriptions, id, subscription_info) 391 + let new_state = 392 + State(..state, subscriptions: new_subscriptions) 393 394 mist.continue(new_state) 395 } ··· 413 let new_subscriptions = dict.delete(state.subscriptions, id) 414 let new_state = State(..state, subscriptions: new_subscriptions) 415 416 + logging.log( 417 + logging.Info, 418 + "[websocket] Subscription completed: " <> id, 419 + ) 420 421 mist.continue(new_state) 422 } ··· 493 case event_matches_subscription(event, subscription_field) { 494 True -> { 495 // Execute the GraphQL subscription query with the event data and variables 496 + case 497 + execute_subscription_query(query, variables, graphql_schema, event, db) 498 + { 499 Ok(result_json) -> { 500 // Send message to handler via Subject 501 process.send(
+8 -4
server/src/importer.gleam
··· 1 import database 2 import gleam/dynamic/decode 3 - import logging 4 import gleam/json 5 import gleam/list 6 import gleam/result 7 import gleam/string 8 import lexicon 9 import simplifile 10 import sqlight 11 ··· 18 directory: String, 19 db: sqlight.Connection, 20 ) -> Result(ImportStats, String) { 21 - 22 // Scan directory for JSON files 23 logging.log(logging.Info, "[import] Scanning directory recursively...") 24 use file_paths <- result.try(scan_directory_recursive(directory)) 25 26 logging.log( 27 logging.Info, 28 - "[import] Found " <> string.inspect(list.length(file_paths)) <> " .json files", 29 ) 30 logging.log(logging.Info, "") 31 logging.log(logging.Info, "[import] Reading all lexicon files...") ··· 48 // Validate all schemas together (this allows cross-references to be resolved) 49 let validation_result = case lexicon.validate_schemas(all_json_strings) { 50 Ok(_) -> { 51 - logging.log(logging.Info, "[import] All lexicons validated successfully") 52 Ok(Nil) 53 } 54 Error(err) -> {
··· 1 import database 2 import gleam/dynamic/decode 3 import gleam/json 4 import gleam/list 5 import gleam/result 6 import gleam/string 7 import lexicon 8 + import logging 9 import simplifile 10 import sqlight 11 ··· 18 directory: String, 19 db: sqlight.Connection, 20 ) -> Result(ImportStats, String) { 21 // Scan directory for JSON files 22 logging.log(logging.Info, "[import] Scanning directory recursively...") 23 use file_paths <- result.try(scan_directory_recursive(directory)) 24 25 logging.log( 26 logging.Info, 27 + "[import] Found " 28 + <> string.inspect(list.length(file_paths)) 29 + <> " .json files", 30 ) 31 logging.log(logging.Info, "") 32 logging.log(logging.Info, "[import] Reading all lexicon files...") ··· 49 // Validate all schemas together (this allows cross-references to be resolved) 50 let validation_result = case lexicon.validate_schemas(all_json_strings) { 51 Ok(_) -> { 52 + logging.log( 53 + logging.Info, 54 + "[import] All lexicons validated successfully", 55 + ) 56 Ok(Nil) 57 } 58 Error(err) -> {
+39 -32
server/src/jetstream_consumer.gleam
··· 6 import gleam/dynamic/decode 7 import gleam/erlang/process 8 import gleam/int 9 - import gleam/otp/actor 10 - import logging 11 import gleam/list 12 import gleam/option 13 import gleam/string 14 import goose 15 import sqlight 16 17 /// Messages that can be sent to the Jetstream consumer actor ··· 22 23 /// Internal state of the Jetstream consumer actor 24 type State { 25 - State( 26 - db: sqlight.Connection, 27 - consumer_pid: option.Option(process.Pid), 28 - ) 29 } 30 31 /// Start the Jetstream consumer actor ··· 41 42 case result { 43 Ok(started) -> Ok(started.data) 44 - Error(err) -> Error("Failed to start consumer actor: " <> string.inspect(err)) 45 } 46 } 47 Error(err) -> { ··· 56 57 case result { 58 Ok(started) -> Ok(started.data) 59 - Error(actor_err) -> Error("Failed to start consumer actor: " <> string.inspect(actor_err)) 60 } 61 } 62 } ··· 68 } 69 70 /// Restart the Jetstream consumer with fresh lexicon data 71 - pub fn restart( 72 - consumer: process.Subject(Message), 73 - ) -> Result(Nil, String) { 74 actor.call(consumer, waiting: 5000, sending: Restart) 75 } 76 ··· 158 159 case all_collection_ids { 160 [] -> { 161 - logging.log(logging.Warning, "[jetstream] No collections found - skipping Jetstream consumer") 162 logging.log(logging.Info, "[jetstream] Import lexicons first") 163 logging.log(logging.Info, "") 164 Error("No collections found") ··· 167 logging.log( 168 logging.Info, 169 "[jetstream] Listening to " 170 - <> int.to_string(list.length(local_collection_ids)) 171 - <> " local collection(s) (all DIDs):", 172 ) 173 - list.each(local_collection_ids, fn(col) { logging.log(logging.Info, "[jetstream] - " <> col) }) 174 175 case external_collection_ids { 176 [] -> Nil ··· 179 logging.log( 180 logging.Info, 181 "[jetstream] Tracking " 182 - <> int.to_string(list.length(external_collection_ids)) 183 - <> " external collection(s) (known DIDs only, filtered client-side):", 184 ) 185 list.each(external_collection_ids, fn(col) { 186 logging.log(logging.Info, "[jetstream] - " <> col) ··· 208 209 logging.log(logging.Info, "") 210 logging.log(logging.Info, "[jetstream] Connecting to Jetstream...") 211 - logging.log(logging.Info, "[jetstream] Endpoint: " <> jetstream_url) 212 logging.log( 213 logging.Info, 214 "[jetstream] Collections: " 215 - <> int.to_string(list.length(all_collection_ids)) 216 - <> " (all DIDs, filtered client-side for external)", 217 ) 218 219 // Start the unified consumer 220 let ext_collections = external_collection_ids 221 - let pid = process.spawn_unlinked(fn() { 222 - goose.start_consumer(unified_config, fn(event_json) { 223 - // Spawn each event into its own process so they don't block each other 224 - let _pid = process.spawn_unlinked(fn() { 225 - handle_jetstream_event( 226 - db, 227 - event_json, 228 - ext_collections, 229 - plc_url, 230 - ) 231 }) 232 - Nil 233 }) 234 - }) 235 236 logging.log(logging.Info, "") 237 logging.log(logging.Info, "[jetstream] Jetstream consumer started")
··· 6 import gleam/dynamic/decode 7 import gleam/erlang/process 8 import gleam/int 9 import gleam/list 10 import gleam/option 11 + import gleam/otp/actor 12 import gleam/string 13 import goose 14 + import logging 15 import sqlight 16 17 /// Messages that can be sent to the Jetstream consumer actor ··· 22 23 /// Internal state of the Jetstream consumer actor 24 type State { 25 + State(db: sqlight.Connection, consumer_pid: option.Option(process.Pid)) 26 } 27 28 /// Start the Jetstream consumer actor ··· 38 39 case result { 40 Ok(started) -> Ok(started.data) 41 + Error(err) -> 42 + Error("Failed to start consumer actor: " <> string.inspect(err)) 43 } 44 } 45 Error(err) -> { ··· 54 55 case result { 56 Ok(started) -> Ok(started.data) 57 + Error(actor_err) -> 58 + Error("Failed to start consumer actor: " <> string.inspect(actor_err)) 59 } 60 } 61 } ··· 67 } 68 69 /// Restart the Jetstream consumer with fresh lexicon data 70 + pub fn restart(consumer: process.Subject(Message)) -> Result(Nil, String) { 71 actor.call(consumer, waiting: 5000, sending: Restart) 72 } 73 ··· 155 156 case all_collection_ids { 157 [] -> { 158 + logging.log( 159 + logging.Warning, 160 + "[jetstream] No collections found - skipping Jetstream consumer", 161 + ) 162 logging.log(logging.Info, "[jetstream] Import lexicons first") 163 logging.log(logging.Info, "") 164 Error("No collections found") ··· 167 logging.log( 168 logging.Info, 169 "[jetstream] Listening to " 170 + <> int.to_string(list.length(local_collection_ids)) 171 + <> " local collection(s) (all DIDs):", 172 ) 173 + list.each(local_collection_ids, fn(col) { 174 + logging.log(logging.Info, "[jetstream] - " <> col) 175 + }) 176 177 case external_collection_ids { 178 [] -> Nil ··· 181 logging.log( 182 logging.Info, 183 "[jetstream] Tracking " 184 + <> int.to_string(list.length(external_collection_ids)) 185 + <> " external collection(s) (known DIDs only, filtered client-side):", 186 ) 187 list.each(external_collection_ids, fn(col) { 188 logging.log(logging.Info, "[jetstream] - " <> col) ··· 210 211 logging.log(logging.Info, "") 212 logging.log(logging.Info, "[jetstream] Connecting to Jetstream...") 213 + logging.log( 214 + logging.Info, 215 + "[jetstream] Endpoint: " <> jetstream_url, 216 + ) 217 logging.log( 218 logging.Info, 219 "[jetstream] Collections: " 220 + <> int.to_string(list.length(all_collection_ids)) 221 + <> " (all DIDs, filtered client-side for external)", 222 ) 223 224 // Start the unified consumer 225 let ext_collections = external_collection_ids 226 + let pid = 227 + process.spawn_unlinked(fn() { 228 + goose.start_consumer(unified_config, fn(event_json) { 229 + // Spawn each event into its own process so they don't block each other 230 + let _pid = 231 + process.spawn_unlinked(fn() { 232 + handle_jetstream_event( 233 + db, 234 + event_json, 235 + ext_collections, 236 + plc_url, 237 + ) 238 + }) 239 + Nil 240 }) 241 }) 242 243 logging.log(logging.Info, "") 244 logging.log(logging.Info, "[jetstream] Jetstream consumer started")
+18 -8
server/src/lustre_handlers.gleam
··· 49 ) -> response.Response(mist.ResponseData) { 50 mist.websocket( 51 request: req, 52 - on_init: init_backfill_button_socket(db, backfill_state_subject, config_subject, _), 53 handler: loop_backfill_button_socket, 54 on_close: close_backfill_button_socket, 55 ) ··· 66 server_component.ClientMessage(backfill_button.Msg) 67 68 type BackfillButtonSocketInit = 69 - #(BackfillButtonSocket, option.Option(process.Selector(BackfillButtonSocketMessage))) 70 71 fn init_backfill_button_socket( 72 db: sqlight.Connection, ··· 78 let is_admin = True 79 80 // Query current backfill state 81 - let backfilling = actor.call( 82 - backfill_state_subject, 83 - waiting: 100, 84 - sending: backfill_state.IsBackfilling, 85 - ) 86 87 - let component = backfill_button.component(db, backfill_state_subject, config_subject) 88 let assert Ok(runtime) = 89 lustre.start_server_component(component, #(is_admin, backfilling)) 90
··· 49 ) -> response.Response(mist.ResponseData) { 50 mist.websocket( 51 request: req, 52 + on_init: init_backfill_button_socket( 53 + db, 54 + backfill_state_subject, 55 + config_subject, 56 + _, 57 + ), 58 handler: loop_backfill_button_socket, 59 on_close: close_backfill_button_socket, 60 ) ··· 71 server_component.ClientMessage(backfill_button.Msg) 72 73 type BackfillButtonSocketInit = 74 + #( 75 + BackfillButtonSocket, 76 + option.Option(process.Selector(BackfillButtonSocketMessage)), 77 + ) 78 79 fn init_backfill_button_socket( 80 db: sqlight.Connection, ··· 86 let is_admin = True 87 88 // Query current backfill state 89 + let backfilling = 90 + actor.call( 91 + backfill_state_subject, 92 + waiting: 100, 93 + sending: backfill_state.IsBackfilling, 94 + ) 95 96 + let component = 97 + backfill_button.component(db, backfill_state_subject, config_subject) 98 let assert Ok(runtime) = 99 lustre.start_server_component(component, #(is_admin, backfilling)) 100
+22 -14
server/src/mutation_resolvers.gleam
··· 15 import gleam/list 16 import gleam/option 17 import gleam/result 18 - import graphql/schema 19 - import graphql/value 20 import lexicon 21 import sqlight 22 23 /// Context for mutation execution 24 pub type MutationContext { ··· 130 ) 131 132 // Step 4: Ensure actor exists in database 133 - use is_new_actor <- result.try( 134 - actor_validator.ensure_actor_exists(ctx.db, user_info.did, ctx.plc_url), 135 - ) 136 137 // If new actor, spawn backfill for external collections 138 case is_new_actor { ··· 313 ) 314 315 // Step 4: Ensure actor exists in database 316 - use is_new_actor <- result.try( 317 - actor_validator.ensure_actor_exists(ctx.db, user_info.did, ctx.plc_url), 318 - ) 319 320 // If new actor, spawn backfill for external collections 321 case is_new_actor { ··· 470 ) 471 472 // Step 4: Ensure actor exists in database 473 - use is_new_actor <- result.try( 474 - actor_validator.ensure_actor_exists(ctx.db, user_info.did, ctx.plc_url), 475 - ) 476 477 // If new actor, spawn backfill for external collections 478 case is_new_actor { ··· 592 ) 593 594 // Step 4: Ensure actor exists in database 595 - use is_new_actor <- result.try( 596 - actor_validator.ensure_actor_exists(ctx.db, user_info.did, ctx.plc_url), 597 - ) 598 599 // If new actor, spawn backfill for external collections 600 case is_new_actor {
··· 15 import gleam/list 16 import gleam/option 17 import gleam/result 18 import lexicon 19 import sqlight 20 + import swell/schema 21 + import swell/value 22 23 /// Context for mutation execution 24 pub type MutationContext { ··· 130 ) 131 132 // Step 4: Ensure actor exists in database 133 + use is_new_actor <- result.try(actor_validator.ensure_actor_exists( 134 + ctx.db, 135 + user_info.did, 136 + ctx.plc_url, 137 + )) 138 139 // If new actor, spawn backfill for external collections 140 case is_new_actor { ··· 315 ) 316 317 // Step 4: Ensure actor exists in database 318 + use is_new_actor <- result.try(actor_validator.ensure_actor_exists( 319 + ctx.db, 320 + user_info.did, 321 + ctx.plc_url, 322 + )) 323 324 // If new actor, spawn backfill for external collections 325 case is_new_actor { ··· 474 ) 475 476 // Step 4: Ensure actor exists in database 477 + use is_new_actor <- result.try(actor_validator.ensure_actor_exists( 478 + ctx.db, 479 + user_info.did, 480 + ctx.plc_url, 481 + )) 482 483 // If new actor, spawn backfill for external collections 484 case is_new_actor { ··· 598 ) 599 600 // Step 4: Ensure actor exists in database 601 + use is_new_actor <- result.try(actor_validator.ensure_actor_exists( 602 + ctx.db, 603 + user_info.did, 604 + ctx.plc_url, 605 + )) 606 607 // If new actor, spawn backfill for external collections 608 case is_new_actor {
+12 -3
server/src/oauth/handlers.gleam
··· 278 let new_refresh_token = case 279 decode.run( 280 parsed, 281 - decode.at(["refresh_token"], decode.optional(decode.string)), 282 ) 283 { 284 Ok(token) -> token ··· 286 } 287 288 let expires_in = case 289 - decode.run(parsed, decode.at(["expires_in"], decode.optional(decode.int))) 290 { 291 Ok(exp) -> exp 292 Error(_) -> option.None ··· 384 let refresh_token = case 385 decode.run( 386 parsed, 387 - decode.at(["refresh_token"], decode.optional(decode.string)), 388 ) 389 { 390 Ok(token) -> token
··· 278 let new_refresh_token = case 279 decode.run( 280 parsed, 281 + decode.at( 282 + ["refresh_token"], 283 + decode.optional(decode.string), 284 + ), 285 ) 286 { 287 Ok(token) -> token ··· 289 } 290 291 let expires_in = case 292 + decode.run( 293 + parsed, 294 + decode.at(["expires_in"], decode.optional(decode.int)), 295 + ) 296 { 297 Ok(exp) -> exp 298 Error(_) -> option.None ··· 390 let refresh_token = case 391 decode.run( 392 parsed, 393 + decode.at( 394 + ["refresh_token"], 395 + decode.optional(decode.string), 396 + ), 397 ) 398 { 399 Ok(token) -> token
+18 -14
server/src/oauth/registration.gleam
··· 36 } 37 38 /// Check registration status by looking at database 39 - pub fn check_registration_status( 40 - db: sqlight.Connection, 41 - ) -> RegistrationStatus { 42 case database.get_oauth_credentials(db) { 43 Ok(option.Some(#(client_id, _client_secret, _redirect_uri))) -> 44 Registered(client_id) ··· 81 ) 82 83 // Store credentials in database 84 - case store_oauth_credentials(db, client_id, client_secret, redirect_uri) { 85 Ok(_) -> { 86 logging.log( 87 logging.Info, ··· 161 case json.parse(resp.body, decode.dynamic) { 162 Ok(parsed) -> { 163 case decode_registration_response(parsed) { 164 - Ok(response) -> Ok(#(response.client_id, response.client_secret)) 165 Error(err) -> Error(err) 166 } 167 } ··· 192 redirect_uri: String, 193 ) -> Result(Nil, sqlight.Error) { 194 use _ <- result.try(database.set_config(db, "oauth_client_id", client_id)) 195 - use _ <- result.try(database.set_config(db, "oauth_client_secret", client_secret)) 196 - use _ <- result.try(database.set_config(db, "oauth_redirect_uri", redirect_uri)) 197 Ok(Nil) 198 } 199 200 // Helper Functions --------------------------------------------------------------- 201 202 - fn encode_registration_request( 203 - req: ClientRegistrationRequest, 204 - ) -> json.Json { 205 json.object([ 206 #("client_name", json.string(req.client_name)), 207 #( ··· 216 "response_types", 217 json.array(req.response_types, fn(response) { json.string(response) }), 218 ), 219 - #( 220 - "token_endpoint_auth_method", 221 - json.string(req.token_endpoint_auth_method), 222 - ), 223 #("scope", json.string(req.scope)), 224 ]) 225 }
··· 36 } 37 38 /// Check registration status by looking at database 39 + pub fn check_registration_status(db: sqlight.Connection) -> RegistrationStatus { 40 case database.get_oauth_credentials(db) { 41 Ok(option.Some(#(client_id, _client_secret, _redirect_uri))) -> 42 Registered(client_id) ··· 79 ) 80 81 // Store credentials in database 82 + case 83 + store_oauth_credentials(db, client_id, client_secret, redirect_uri) 84 + { 85 Ok(_) -> { 86 logging.log( 87 logging.Info, ··· 161 case json.parse(resp.body, decode.dynamic) { 162 Ok(parsed) -> { 163 case decode_registration_response(parsed) { 164 + Ok(response) -> 165 + Ok(#(response.client_id, response.client_secret)) 166 Error(err) -> Error(err) 167 } 168 } ··· 193 redirect_uri: String, 194 ) -> Result(Nil, sqlight.Error) { 195 use _ <- result.try(database.set_config(db, "oauth_client_id", client_id)) 196 + use _ <- result.try(database.set_config( 197 + db, 198 + "oauth_client_secret", 199 + client_secret, 200 + )) 201 + use _ <- result.try(database.set_config( 202 + db, 203 + "oauth_redirect_uri", 204 + redirect_uri, 205 + )) 206 Ok(Nil) 207 } 208 209 // Helper Functions --------------------------------------------------------------- 210 211 + fn encode_registration_request(req: ClientRegistrationRequest) -> json.Json { 212 json.object([ 213 #("client_name", json.string(req.client_name)), 214 #( ··· 223 "response_types", 224 json.array(req.response_types, fn(response) { json.string(response) }), 225 ), 226 + #("token_endpoint_auth_method", json.string(req.token_endpoint_auth_method)), 227 #("scope", json.string(req.scope)), 228 ]) 229 }
+6 -6
server/src/oauth/session.gleam
··· 74 let session_id = generate_session_id() 75 76 let sql = case expires_in { 77 - option.Some(seconds) -> 78 - " 79 INSERT INTO oauth_sessions (session_id, access_token, refresh_token, did, handle, expires_at) 80 VALUES (?, ?, ?, ?, ?, unixepoch() + " <> int.to_string(seconds) <> ") 81 " ··· 164 expires_in: Option(Int), 165 ) -> Result(Nil, sqlight.Error) { 166 let sql = case expires_in { 167 - option.Some(seconds) -> 168 - " 169 UPDATE oauth_sessions 170 SET access_token = ?, 171 refresh_token = ?, ··· 244 pub fn get_current_user( 245 req: Request, 246 db: Connection, 247 - refresh_fn: fn(String) -> Result(#(String, Option(String), Option(Int)), String), 248 ) -> Result(#(String, String, String), Nil) { 249 // Get the full session to check expiration 250 use sess <- result.try(get_current_session(req, db)) ··· 259 case refresh_fn(refresh_tok) { 260 Ok(#(new_access_token, new_refresh_token, expires_in)) -> { 261 // Update session with new tokens 262 - let final_refresh = option.or(new_refresh_token, sess.refresh_token) 263 let _ = 264 update_session_tokens( 265 db,
··· 74 let session_id = generate_session_id() 75 76 let sql = case expires_in { 77 + option.Some(seconds) -> " 78 INSERT INTO oauth_sessions (session_id, access_token, refresh_token, did, handle, expires_at) 79 VALUES (?, ?, ?, ?, ?, unixepoch() + " <> int.to_string(seconds) <> ") 80 " ··· 163 expires_in: Option(Int), 164 ) -> Result(Nil, sqlight.Error) { 165 let sql = case expires_in { 166 + option.Some(seconds) -> " 167 UPDATE oauth_sessions 168 SET access_token = ?, 169 refresh_token = ?, ··· 242 pub fn get_current_user( 243 req: Request, 244 db: Connection, 245 + refresh_fn: fn(String) -> 246 + Result(#(String, Option(String), Option(Int)), String), 247 ) -> Result(#(String, String, String), Nil) { 248 // Get the full session to check expiration 249 use sess <- result.try(get_current_session(req, db)) ··· 258 case refresh_fn(refresh_tok) { 259 Ok(#(new_access_token, new_refresh_token, expires_in)) -> { 260 // Update session with new tokens 261 + let final_refresh = 262 + option.or(new_refresh_token, sess.refresh_token) 263 let _ = 264 update_session_tokens( 265 db,
+17 -4
server/src/pages/index.gleam
··· 89 content: [ 90 render_alerts(domain_authority, data.lexicon_count), 91 render_action_buttons(current_user), 92 - render_stats_section(data.record_count, data.lexicon_count, data.actor_count), 93 render_activity_section(data.record_activity), 94 render_collections_section( 95 data.collection_stats, ··· 143 } 144 145 /// Render action buttons for authenticated users 146 - fn render_action_buttons(current_user: Option(#(String, String))) -> Element(msg) { 147 case current_user { 148 option.Some(_) -> { 149 html.div([attribute.class("mb-8 flex gap-3")], [ ··· 155 } 156 157 /// Render the combined statistics section 158 - fn render_stats_section(record_count: Int, lexicon_count: Int, actor_count: Int) -> Element(msg) { 159 html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 160 // Total records stat card 161 html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ ··· 210 let backfill_button = case is_admin { 211 True -> 212 server_component.element( 213 - [attribute.id("backfill-button"), server_component.route("/backfill-ws")], 214 [], 215 ) 216 False -> element.none()
··· 89 content: [ 90 render_alerts(domain_authority, data.lexicon_count), 91 render_action_buttons(current_user), 92 + render_stats_section( 93 + data.record_count, 94 + data.lexicon_count, 95 + data.actor_count, 96 + ), 97 render_activity_section(data.record_activity), 98 render_collections_section( 99 data.collection_stats, ··· 147 } 148 149 /// Render action buttons for authenticated users 150 + fn render_action_buttons( 151 + current_user: Option(#(String, String)), 152 + ) -> Element(msg) { 153 case current_user { 154 option.Some(_) -> { 155 html.div([attribute.class("mb-8 flex gap-3")], [ ··· 161 } 162 163 /// Render the combined statistics section 164 + fn render_stats_section( 165 + record_count: Int, 166 + lexicon_count: Int, 167 + actor_count: Int, 168 + ) -> Element(msg) { 169 html.div([attribute.class("mb-8 grid grid-cols-3 gap-4")], [ 170 // Total records stat card 171 html.div([attribute.class("bg-zinc-800/50 rounded p-4")], [ ··· 220 let backfill_button = case is_admin { 221 True -> 222 server_component.element( 223 + [ 224 + attribute.id("backfill-button"), 225 + server_component.route("/backfill-ws"), 226 + ], 227 [], 228 ) 229 False -> element.none()
+28 -29
server/src/pages/settings.gleam
··· 21 22 /// Settings data 23 pub type SettingsData { 24 - SettingsData( 25 - domain_authority: String, 26 - oauth_client_id: Option(String), 27 - ) 28 } 29 30 /// Fetch current settings ··· 39 _ -> option.None 40 } 41 42 - SettingsData(domain_authority: domain_authority, oauth_client_id: oauth_client_id) 43 } 44 45 /// Render the complete settings page ··· 149 option.Some(client_id) -> { 150 html.div([attribute.class("space-y-3")], [ 151 html.div([attribute.class("flex items-center gap-2")], [ 152 - html.div([ 153 - attribute.class( 154 - "w-2 h-2 bg-green-500 rounded-full", 155 - ), 156 - ], []), 157 html.p([attribute.class("text-sm text-zinc-300")], [ 158 element.text("OAuth client registered"), 159 ]), ··· 176 option.None -> { 177 html.div([attribute.class("space-y-3")], [ 178 html.div([attribute.class("flex items-center gap-2")], [ 179 - html.div([ 180 - attribute.class( 181 - "w-2 h-2 bg-zinc-500 rounded-full", 182 - ), 183 - ], []), 184 html.p([attribute.class("text-sm text-zinc-400")], [ 185 element.text("OAuth client not registered"), 186 ]), ··· 249 html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 250 element.text("Account"), 251 ]), 252 - html.form( 253 - [attribute.method("post"), attribute.action("/logout")], 254 - [ 255 - html.button( 256 - [ 257 - attribute.type_("submit"), 258 - attribute.class( 259 - "font-mono px-4 py-2 text-sm text-zinc-400 border border-zinc-700 hover:border-zinc-600 hover:text-zinc-300 rounded transition-colors cursor-pointer", 260 - ), 261 - ], 262 - [element.text("Sign Out")], 263 - ), 264 - ], 265 - ), 266 ]), 267 ]) 268 }
··· 21 22 /// Settings data 23 pub type SettingsData { 24 + SettingsData(domain_authority: String, oauth_client_id: Option(String)) 25 } 26 27 /// Fetch current settings ··· 36 _ -> option.None 37 } 38 39 + SettingsData( 40 + domain_authority: domain_authority, 41 + oauth_client_id: oauth_client_id, 42 + ) 43 } 44 45 /// Render the complete settings page ··· 149 option.Some(client_id) -> { 150 html.div([attribute.class("space-y-3")], [ 151 html.div([attribute.class("flex items-center gap-2")], [ 152 + html.div( 153 + [ 154 + attribute.class("w-2 h-2 bg-green-500 rounded-full"), 155 + ], 156 + [], 157 + ), 158 html.p([attribute.class("text-sm text-zinc-300")], [ 159 element.text("OAuth client registered"), 160 ]), ··· 177 option.None -> { 178 html.div([attribute.class("space-y-3")], [ 179 html.div([attribute.class("flex items-center gap-2")], [ 180 + html.div( 181 + [ 182 + attribute.class("w-2 h-2 bg-zinc-500 rounded-full"), 183 + ], 184 + [], 185 + ), 186 html.p([attribute.class("text-sm text-zinc-400")], [ 187 element.text("OAuth client not registered"), 188 ]), ··· 251 html.h2([attribute.class("text-xl font-semibold text-zinc-300 mb-4")], [ 252 element.text("Account"), 253 ]), 254 + html.form([attribute.method("post"), attribute.action("/logout")], [ 255 + html.button( 256 + [ 257 + attribute.type_("submit"), 258 + attribute.class( 259 + "font-mono px-4 py-2 text-sm text-zinc-400 border border-zinc-700 hover:border-zinc-600 hover:text-zinc-300 rounded transition-colors cursor-pointer", 260 + ), 261 + ], 262 + [element.text("Sign Out")], 263 + ), 264 + ]), 265 ]), 266 ]) 267 }
+15 -8
server/src/server.gleam
··· 43 admin_dids: List(String), 44 backfill_state: process.Subject(backfill_state.Message), 45 config: process.Subject(config.Message), 46 - jetstream_consumer: option.Option(process.Subject(jetstream_consumer.Message)), 47 ) 48 } 49 ··· 477 Ok(upgrade_value) -> { 478 case string.lowercase(upgrade_value) { 479 "websocket" -> { 480 - logging.log(logging.Info, "[server] WebSocket upgrade for /backfill-ws") 481 lustre_handlers.serve_backfill_button( 482 req, 483 ctx.db, ··· 502 logging.Info, 503 "[server] Handling WebSocket upgrade for /graphql", 504 ) 505 - let domain_authority = case config.get_domain_authority(ctx.config) { 506 option.Some(authority) -> authority 507 option.None -> "" 508 } ··· 634 } 635 } 636 637 - fn handle_backfill_request( 638 - req: wisp.Request, 639 - ctx: Context, 640 - ) -> wisp.Response { 641 case req.method { 642 gleam_http.Post -> { 643 // Get domain authority from config ··· 662 let #(collections, external_collections) = 663 lexicons 664 |> list.partition(fn(lex) { 665 - backfill.nsid_matches_domain_authority(lex.id, domain_authority) 666 }) 667 668 let collection_ids = list.map(collections, fn(lex) { lex.id })
··· 43 admin_dids: List(String), 44 backfill_state: process.Subject(backfill_state.Message), 45 config: process.Subject(config.Message), 46 + jetstream_consumer: option.Option( 47 + process.Subject(jetstream_consumer.Message), 48 + ), 49 ) 50 } 51 ··· 479 Ok(upgrade_value) -> { 480 case string.lowercase(upgrade_value) { 481 "websocket" -> { 482 + logging.log( 483 + logging.Info, 484 + "[server] WebSocket upgrade for /backfill-ws", 485 + ) 486 lustre_handlers.serve_backfill_button( 487 req, 488 ctx.db, ··· 507 logging.Info, 508 "[server] Handling WebSocket upgrade for /graphql", 509 ) 510 + let domain_authority = case 511 + config.get_domain_authority(ctx.config) 512 + { 513 option.Some(authority) -> authority 514 option.None -> "" 515 } ··· 641 } 642 } 643 644 + fn handle_backfill_request(req: wisp.Request, ctx: Context) -> wisp.Response { 645 case req.method { 646 gleam_http.Post -> { 647 // Get domain authority from config ··· 666 let #(collections, external_collections) = 667 lexicons 668 |> list.partition(fn(lex) { 669 + backfill.nsid_matches_domain_authority( 670 + lex.id, 671 + domain_authority, 672 + ) 673 }) 674 675 let collection_ids = list.map(collections, fn(lex) { lex.id })
+109 -25
server/src/settings_handler.gleam
··· 25 oauth_config: handlers.OAuthConfig, 26 admin_dids: List(String), 27 config: process.Subject(config.Message), 28 - jetstream_consumer: option.Option(process.Subject(jetstream_consumer.Message)), 29 ) 30 } 31 ··· 48 // Require admin access for the entire settings page 49 case user_is_admin { 50 False -> { 51 - logging.log(logging.Warning, "[settings] Non-admin user attempted to access settings page") 52 wisp.redirect("/") 53 } 54 True -> handle_admin_request(req, ctx, current_user) ··· 94 case validate_domain_authority(domain_authority) { 95 Ok(_) -> { 96 // Save domain_authority to database and update cache 97 - case config.set_domain_authority(ctx.config, ctx.db, domain_authority) { 98 Ok(_) -> { 99 wisp.redirect("/settings") 100 - |> wisp_flash.set_flash(req, "success", "Domain authority saved successfully") 101 } 102 Error(_) -> { 103 - logging.log(logging.Error, "[settings] Failed to save domain_authority") 104 wisp.redirect("/settings") 105 - |> wisp_flash.set_flash(req, "error", "Failed to save domain authority") 106 } 107 } 108 } 109 Error(error_message) -> { 110 - logging.log(logging.Warning, "[settings] Invalid domain authority: " <> error_message) 111 wisp.redirect("/settings") 112 |> wisp_flash.set_flash(req, "error", error_message) 113 } 114 } 115 } 116 Error(_) -> { 117 - logging.log(logging.Warning, "[settings] No form data received") 118 wisp.redirect("/settings") 119 } 120 } ··· 140 uploaded_file: wisp.UploadedFile, 141 ctx: Context, 142 ) -> wisp.Response { 143 - logging.log(logging.Info, "[settings] Processing lexicons ZIP upload: " <> uploaded_file.file_name) 144 145 // Create temporary directory for extraction with random suffix 146 let temp_dir = "tmp/lexicon_upload_" <> wisp.random_string(16) 147 148 case simplifile.create_directory_all(temp_dir) { 149 Ok(_) -> { 150 - logging.log(logging.Info, "[settings] Created temp directory: " <> temp_dir) 151 152 // Extract ZIP file to temp directory 153 case zip_helper.extract_zip(uploaded_file.path, temp_dir) { 154 Ok(_) -> { 155 - logging.log(logging.Info, "[settings] Extracted ZIP file to: " <> temp_dir) 156 157 // Import lexicons from extracted directory 158 case importer.import_lexicons_from_directory(temp_dir, ctx.db) { ··· 174 [] -> Nil 175 errors -> { 176 list.each(errors, fn(err) { 177 - logging.log(logging.Warning, "[settings] Import error: " <> err) 178 }) 179 } 180 } ··· 182 // Restart Jetstream consumer to pick up newly imported collections 183 let restart_status = case ctx.jetstream_consumer { 184 option.Some(consumer) -> { 185 - logging.log(logging.Info, "[settings] Restarting Jetstream consumer with new lexicons...") 186 case jetstream_consumer.restart(consumer) { 187 Ok(_) -> { 188 - logging.log(logging.Info, "[settings] Jetstream consumer restarted successfully") 189 "success" 190 } 191 Error(err) -> { 192 - logging.log(logging.Error, "[settings] Failed to restart Jetstream consumer: " <> err) 193 "failed" 194 } 195 } 196 } 197 option.None -> { 198 - logging.log(logging.Info, "[settings] Jetstream consumer not running, skipping restart") 199 "not_running" 200 } 201 } 202 203 // Build success message with import stats and restart status 204 - let base_message = "Imported " <> int.to_string(stats.imported) <> " lexicon(s) successfully" 205 let message = case restart_status { 206 "success" -> base_message <> ". Jetstream consumer restarted." 207 - "failed" -> base_message <> ". Warning: Jetstream consumer restart failed." 208 "not_running" -> base_message <> "." 209 _ -> base_message 210 } ··· 221 // Clean up temp directory 222 let _ = simplifile.delete(temp_dir) 223 224 - logging.log(logging.Error, "[settings] Failed to import lexicons: " <> err) 225 wisp.redirect("/settings") 226 - |> wisp_flash.set_flash(req, "error", "Failed to import lexicons: " <> err) 227 } 228 } 229 } ··· 231 // Clean up temp directory 232 let _ = simplifile.delete(temp_dir) 233 234 - logging.log(logging.Error, "[settings] Failed to extract ZIP: " <> err) 235 wisp.redirect("/settings") 236 - |> wisp_flash.set_flash(req, "error", "Failed to extract ZIP file: " <> err) 237 } 238 } 239 } 240 Error(_) -> { 241 logging.log(logging.Error, "[settings] Failed to create temp directory") 242 wisp.redirect("/settings") 243 - |> wisp_flash.set_flash(req, "error", "Failed to create temporary directory for upload") 244 } 245 } 246 } ··· 268 // Check that no parts are empty 269 case list.all(parts, fn(part) { string.length(part) > 0 }) { 270 False -> 271 - Error("Domain authority parts cannot be empty (e.g., com.example)") 272 True -> Ok(Nil) 273 } 274 } ··· 293 let actors_result = database.delete_all_actors(ctx.db) 294 let oauth_result = database.delete_oauth_credentials(ctx.db) 295 296 - case domain_result, lexicons_result, records_result, actors_result, oauth_result { 297 Ok(_), Ok(_), Ok(_), Ok(_), Ok(_) -> { 298 logging.log( 299 logging.Info,
··· 25 oauth_config: handlers.OAuthConfig, 26 admin_dids: List(String), 27 config: process.Subject(config.Message), 28 + jetstream_consumer: option.Option( 29 + process.Subject(jetstream_consumer.Message), 30 + ), 31 ) 32 } 33 ··· 50 // Require admin access for the entire settings page 51 case user_is_admin { 52 False -> { 53 + logging.log( 54 + logging.Warning, 55 + "[settings] Non-admin user attempted to access settings page", 56 + ) 57 wisp.redirect("/") 58 } 59 True -> handle_admin_request(req, ctx, current_user) ··· 99 case validate_domain_authority(domain_authority) { 100 Ok(_) -> { 101 // Save domain_authority to database and update cache 102 + case 103 + config.set_domain_authority( 104 + ctx.config, 105 + ctx.db, 106 + domain_authority, 107 + ) 108 + { 109 Ok(_) -> { 110 wisp.redirect("/settings") 111 + |> wisp_flash.set_flash( 112 + req, 113 + "success", 114 + "Domain authority saved successfully", 115 + ) 116 } 117 Error(_) -> { 118 + logging.log( 119 + logging.Error, 120 + "[settings] Failed to save domain_authority", 121 + ) 122 wisp.redirect("/settings") 123 + |> wisp_flash.set_flash( 124 + req, 125 + "error", 126 + "Failed to save domain authority", 127 + ) 128 } 129 } 130 } 131 Error(error_message) -> { 132 + logging.log( 133 + logging.Warning, 134 + "[settings] Invalid domain authority: " <> error_message, 135 + ) 136 wisp.redirect("/settings") 137 |> wisp_flash.set_flash(req, "error", error_message) 138 } 139 } 140 } 141 Error(_) -> { 142 + logging.log( 143 + logging.Warning, 144 + "[settings] No form data received", 145 + ) 146 wisp.redirect("/settings") 147 } 148 } ··· 168 uploaded_file: wisp.UploadedFile, 169 ctx: Context, 170 ) -> wisp.Response { 171 + logging.log( 172 + logging.Info, 173 + "[settings] Processing lexicons ZIP upload: " <> uploaded_file.file_name, 174 + ) 175 176 // Create temporary directory for extraction with random suffix 177 let temp_dir = "tmp/lexicon_upload_" <> wisp.random_string(16) 178 179 case simplifile.create_directory_all(temp_dir) { 180 Ok(_) -> { 181 + logging.log( 182 + logging.Info, 183 + "[settings] Created temp directory: " <> temp_dir, 184 + ) 185 186 // Extract ZIP file to temp directory 187 case zip_helper.extract_zip(uploaded_file.path, temp_dir) { 188 Ok(_) -> { 189 + logging.log( 190 + logging.Info, 191 + "[settings] Extracted ZIP file to: " <> temp_dir, 192 + ) 193 194 // Import lexicons from extracted directory 195 case importer.import_lexicons_from_directory(temp_dir, ctx.db) { ··· 211 [] -> Nil 212 errors -> { 213 list.each(errors, fn(err) { 214 + logging.log( 215 + logging.Warning, 216 + "[settings] Import error: " <> err, 217 + ) 218 }) 219 } 220 } ··· 222 // Restart Jetstream consumer to pick up newly imported collections 223 let restart_status = case ctx.jetstream_consumer { 224 option.Some(consumer) -> { 225 + logging.log( 226 + logging.Info, 227 + "[settings] Restarting Jetstream consumer with new lexicons...", 228 + ) 229 case jetstream_consumer.restart(consumer) { 230 Ok(_) -> { 231 + logging.log( 232 + logging.Info, 233 + "[settings] Jetstream consumer restarted successfully", 234 + ) 235 "success" 236 } 237 Error(err) -> { 238 + logging.log( 239 + logging.Error, 240 + "[settings] Failed to restart Jetstream consumer: " 241 + <> err, 242 + ) 243 "failed" 244 } 245 } 246 } 247 option.None -> { 248 + logging.log( 249 + logging.Info, 250 + "[settings] Jetstream consumer not running, skipping restart", 251 + ) 252 "not_running" 253 } 254 } 255 256 // Build success message with import stats and restart status 257 + let base_message = 258 + "Imported " 259 + <> int.to_string(stats.imported) 260 + <> " lexicon(s) successfully" 261 let message = case restart_status { 262 "success" -> base_message <> ". Jetstream consumer restarted." 263 + "failed" -> 264 + base_message 265 + <> ". Warning: Jetstream consumer restart failed." 266 "not_running" -> base_message <> "." 267 _ -> base_message 268 } ··· 279 // Clean up temp directory 280 let _ = simplifile.delete(temp_dir) 281 282 + logging.log( 283 + logging.Error, 284 + "[settings] Failed to import lexicons: " <> err, 285 + ) 286 wisp.redirect("/settings") 287 + |> wisp_flash.set_flash( 288 + req, 289 + "error", 290 + "Failed to import lexicons: " <> err, 291 + ) 292 } 293 } 294 } ··· 296 // Clean up temp directory 297 let _ = simplifile.delete(temp_dir) 298 299 + logging.log( 300 + logging.Error, 301 + "[settings] Failed to extract ZIP: " <> err, 302 + ) 303 wisp.redirect("/settings") 304 + |> wisp_flash.set_flash( 305 + req, 306 + "error", 307 + "Failed to extract ZIP file: " <> err, 308 + ) 309 } 310 } 311 } 312 Error(_) -> { 313 logging.log(logging.Error, "[settings] Failed to create temp directory") 314 wisp.redirect("/settings") 315 + |> wisp_flash.set_flash( 316 + req, 317 + "error", 318 + "Failed to create temporary directory for upload", 319 + ) 320 } 321 } 322 } ··· 344 // Check that no parts are empty 345 case list.all(parts, fn(part) { string.length(part) > 0 }) { 346 False -> 347 + Error( 348 + "Domain authority parts cannot be empty (e.g., com.example)", 349 + ) 350 True -> Ok(Nil) 351 } 352 } ··· 371 let actors_result = database.delete_all_actors(ctx.db) 372 let oauth_result = database.delete_oauth_credentials(ctx.db) 373 374 + case 375 + domain_result, 376 + lexicons_result, 377 + records_result, 378 + actors_result, 379 + oauth_result 380 + { 381 Ok(_), Ok(_), Ok(_), Ok(_), Ok(_) -> { 382 logging.log( 383 logging.Info,
+5 -5
server/src/zip_helper.gleam
··· 6 /// Extract a ZIP file to a destination directory 7 /// 8 /// Returns Ok(Nil) on success, Error(String) on failure 9 - pub fn extract_zip( 10 - zip_path: String, 11 - destination: String, 12 - ) -> Result(Nil, String) { 13 case do_extract_zip(zip_path, destination) { 14 Ok(_) -> Ok(Nil) 15 Error(err) -> Error(dynamic_to_string(err)) ··· 19 /// Erlang FFI to unzip a file 20 /// Uses :zip.unzip/2 with the :cwd option to specify extraction directory 21 @external(erlang, "zip_helper_ffi", "unzip_file") 22 - fn do_extract_zip(zip_path: String, destination: String) -> Result(Dynamic, Dynamic) 23 24 /// Convert a dynamic error to a string for error reporting 25 fn dynamic_to_string(value: Dynamic) -> String {
··· 6 /// Extract a ZIP file to a destination directory 7 /// 8 /// Returns Ok(Nil) on success, Error(String) on failure 9 + pub fn extract_zip(zip_path: String, destination: String) -> Result(Nil, String) { 10 case do_extract_zip(zip_path, destination) { 11 Ok(_) -> Ok(Nil) 12 Error(err) -> Error(dynamic_to_string(err)) ··· 16 /// Erlang FFI to unzip a file 17 /// Uses :zip.unzip/2 with the :cwd option to specify extraction directory 18 @external(erlang, "zip_helper_ffi", "unzip_file") 19 + fn do_extract_zip( 20 + zip_path: String, 21 + destination: String, 22 + ) -> Result(Dynamic, Dynamic) 23 24 /// Convert a dynamic error to a string for error reporting 25 fn dynamic_to_string(value: Dynamic) -> String {
+24 -4
server/test/blob_integration_test.gleam
··· 116 |> simulate.header("content-type", "application/json") 117 118 let response = 119 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 120 121 // Verify response 122 response.status ··· 205 |> simulate.header("content-type", "application/json") 206 207 let response = 208 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 209 210 response.status 211 |> should.equal(200) ··· 269 |> simulate.header("content-type", "application/json") 270 271 let response = 272 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 273 274 response.status 275 |> should.equal(200) ··· 325 |> simulate.header("content-type", "application/json") 326 327 let response = 328 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 329 330 response.status 331 |> should.equal(200)
··· 116 |> simulate.header("content-type", "application/json") 117 118 let response = 119 + graphql_handler.handle_graphql_request( 120 + request, 121 + db, 122 + "http://localhost:3000", 123 + "https://plc.directory", 124 + ) 125 126 // Verify response 127 response.status ··· 210 |> simulate.header("content-type", "application/json") 211 212 let response = 213 + graphql_handler.handle_graphql_request( 214 + request, 215 + db, 216 + "http://localhost:3000", 217 + "https://plc.directory", 218 + ) 219 220 response.status 221 |> should.equal(200) ··· 279 |> simulate.header("content-type", "application/json") 280 281 let response = 282 + graphql_handler.handle_graphql_request( 283 + request, 284 + db, 285 + "http://localhost:3000", 286 + "https://plc.directory", 287 + ) 288 289 response.status 290 |> should.equal(200) ··· 340 |> simulate.header("content-type", "application/json") 341 342 let response = 343 + graphql_handler.handle_graphql_request( 344 + request, 345 + db, 346 + "http://localhost:3000", 347 + "https://plc.directory", 348 + ) 349 350 response.status 351 |> should.equal(200)
+54 -9
server/test/graphql_handler_integration_test.gleam
··· 167 |> simulate.header("content-type", "application/json") 168 169 let response = 170 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 171 172 // Verify response 173 response.status ··· 235 |> simulate.header("content-type", "application/json") 236 237 let response = 238 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 239 240 // Verify response 241 response.status ··· 271 ) 272 273 let response = 274 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 275 276 // Verify response 277 response.status ··· 300 |> simulate.header("content-type", "application/json") 301 302 let response = 303 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 304 305 // Should return 400 Bad Request 306 response.status ··· 333 |> simulate.header("content-type", "application/json") 334 335 let response = 336 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 337 338 // Should return 400 Bad Request 339 response.status ··· 358 let request = simulate.request(http.Delete, "/graphql") 359 360 let response = 361 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 362 363 // Should return 405 Method Not Allowed 364 response.status ··· 570 |> simulate.header("content-type", "application/json") 571 572 let response = 573 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 574 575 response.status 576 |> should.equal(200) ··· 679 |> simulate.header("content-type", "application/json") 680 681 let response = 682 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 683 684 // Verify response 685 response.status ··· 791 |> simulate.header("content-type", "application/json") 792 793 let response = 794 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 795 796 // Verify response 797 response.status
··· 167 |> simulate.header("content-type", "application/json") 168 169 let response = 170 + graphql_handler.handle_graphql_request( 171 + request, 172 + db, 173 + "http://localhost:3000", 174 + "https://plc.directory", 175 + ) 176 177 // Verify response 178 response.status ··· 240 |> simulate.header("content-type", "application/json") 241 242 let response = 243 + graphql_handler.handle_graphql_request( 244 + request, 245 + db, 246 + "http://localhost:3000", 247 + "https://plc.directory", 248 + ) 249 250 // Verify response 251 response.status ··· 281 ) 282 283 let response = 284 + graphql_handler.handle_graphql_request( 285 + request, 286 + db, 287 + "http://localhost:3000", 288 + "https://plc.directory", 289 + ) 290 291 // Verify response 292 response.status ··· 315 |> simulate.header("content-type", "application/json") 316 317 let response = 318 + graphql_handler.handle_graphql_request( 319 + request, 320 + db, 321 + "http://localhost:3000", 322 + "https://plc.directory", 323 + ) 324 325 // Should return 400 Bad Request 326 response.status ··· 353 |> simulate.header("content-type", "application/json") 354 355 let response = 356 + graphql_handler.handle_graphql_request( 357 + request, 358 + db, 359 + "http://localhost:3000", 360 + "https://plc.directory", 361 + ) 362 363 // Should return 400 Bad Request 364 response.status ··· 383 let request = simulate.request(http.Delete, "/graphql") 384 385 let response = 386 + graphql_handler.handle_graphql_request( 387 + request, 388 + db, 389 + "http://localhost:3000", 390 + "https://plc.directory", 391 + ) 392 393 // Should return 405 Method Not Allowed 394 response.status ··· 600 |> simulate.header("content-type", "application/json") 601 602 let response = 603 + graphql_handler.handle_graphql_request( 604 + request, 605 + db, 606 + "http://localhost:3000", 607 + "https://plc.directory", 608 + ) 609 610 response.status 611 |> should.equal(200) ··· 714 |> simulate.header("content-type", "application/json") 715 716 let response = 717 + graphql_handler.handle_graphql_request( 718 + request, 719 + db, 720 + "http://localhost:3000", 721 + "https://plc.directory", 722 + ) 723 724 // Verify response 725 response.status ··· 831 |> simulate.header("content-type", "application/json") 832 833 let response = 834 + graphql_handler.handle_graphql_request( 835 + request, 836 + db, 837 + "http://localhost:3000", 838 + "https://plc.directory", 839 + ) 840 841 // Verify response 842 response.status
+24 -4
server/test/graphql_introspection_did_join_test.gleam
··· 120 |> simulate.header("content-type", "application/json") 121 122 let response = 123 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 124 125 // Verify response 126 response.status ··· 201 |> simulate.header("content-type", "application/json") 202 203 let response = 204 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 205 206 // Verify response 207 response.status ··· 363 |> simulate.header("content-type", "application/json") 364 365 let response = 366 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 367 368 // Verify response 369 response.status ··· 480 |> simulate.header("content-type", "application/json") 481 482 let response = 483 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 484 485 // Verify response 486 response.status
··· 120 |> simulate.header("content-type", "application/json") 121 122 let response = 123 + graphql_handler.handle_graphql_request( 124 + request, 125 + db, 126 + "http://localhost:3000", 127 + "https://plc.directory", 128 + ) 129 130 // Verify response 131 response.status ··· 206 |> simulate.header("content-type", "application/json") 207 208 let response = 209 + graphql_handler.handle_graphql_request( 210 + request, 211 + db, 212 + "http://localhost:3000", 213 + "https://plc.directory", 214 + ) 215 216 // Verify response 217 response.status ··· 373 |> simulate.header("content-type", "application/json") 374 375 let response = 376 + graphql_handler.handle_graphql_request( 377 + request, 378 + db, 379 + "http://localhost:3000", 380 + "https://plc.directory", 381 + ) 382 383 // Verify response 384 response.status ··· 495 |> simulate.header("content-type", "application/json") 496 497 let response = 498 + graphql_handler.handle_graphql_request( 499 + request, 500 + db, 501 + "http://localhost:3000", 502 + "https://plc.directory", 503 + ) 504 505 // Verify response 506 response.status
+1 -1
server/test/graphql_mutation_integration_test.gleam
··· 3 import gleam/list 4 import gleam/option 5 import gleeunit/should 6 - import graphql/schema 7 import lexicon_graphql/db_schema_builder 8 import lexicon_graphql/lexicon_parser 9 import sqlight 10 11 // Helper to create a status lexicon JSON 12 fn create_status_lexicon() -> String {
··· 3 import gleam/list 4 import gleam/option 5 import gleeunit/should 6 import lexicon_graphql/db_schema_builder 7 import lexicon_graphql/lexicon_parser 8 import sqlight 9 + import swell/schema 10 11 // Helper to create a status lexicon JSON 12 fn create_status_lexicon() -> String {
+24 -4
server/test/graphql_total_count_test.gleam
··· 135 |> simulate.header("content-type", "application/json") 136 137 let response = 138 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 139 140 // Verify response 141 response.status ··· 238 |> simulate.header("content-type", "application/json") 239 240 let response = 241 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 242 243 // Verify response 244 response.status ··· 294 |> simulate.header("content-type", "application/json") 295 296 let response = 297 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 298 299 // Verify response 300 response.status ··· 363 |> simulate.header("content-type", "application/json") 364 365 let response = 366 - graphql_handler.handle_graphql_request(request, db, "http://localhost:3000", "https://plc.directory") 367 368 // Verify response 369 response.status
··· 135 |> simulate.header("content-type", "application/json") 136 137 let response = 138 + graphql_handler.handle_graphql_request( 139 + request, 140 + db, 141 + "http://localhost:3000", 142 + "https://plc.directory", 143 + ) 144 145 // Verify response 146 response.status ··· 243 |> simulate.header("content-type", "application/json") 244 245 let response = 246 + graphql_handler.handle_graphql_request( 247 + request, 248 + db, 249 + "http://localhost:3000", 250 + "https://plc.directory", 251 + ) 252 253 // Verify response 254 response.status ··· 304 |> simulate.header("content-type", "application/json") 305 306 let response = 307 + graphql_handler.handle_graphql_request( 308 + request, 309 + db, 310 + "http://localhost:3000", 311 + "https://plc.directory", 312 + ) 313 314 // Verify response 315 response.status ··· 378 |> simulate.header("content-type", "application/json") 379 380 let response = 381 + graphql_handler.handle_graphql_request( 382 + request, 383 + db, 384 + "http://localhost:3000", 385 + "https://plc.directory", 386 + ) 387 388 // Verify response 389 response.status
+1 -1
server/test/graphql_where_integration_test.gleam
··· 8 import gleam/string 9 import gleeunit 10 import gleeunit/should 11 - import graphql/value 12 import lexicon_graphql/where_input 13 import sqlight 14 import where_converter 15 16 pub fn main() {
··· 8 import gleam/string 9 import gleeunit 10 import gleeunit/should 11 import lexicon_graphql/where_input 12 import sqlight 13 + import swell/value 14 import where_converter 15 16 pub fn main() {
+72 -9
server/test/join_integration_test.gleam
··· 222 " 223 224 let assert Ok(response_json) = 225 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 226 227 // Verify the response contains resolved join with parent URI 228 string.contains(response_json, reply_uri) ··· 311 " 312 313 let assert Ok(response_json) = 314 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 315 316 // Verify the response contains resolved strongRef join with post URI 317 string.contains(response_json, profile_uri) ··· 418 " 419 420 let assert Ok(response_json) = 421 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 422 423 // Verify the response contains reverse join results 424 string.contains(response_json, post_uri) ··· 535 " 536 537 let assert Ok(response_json) = 538 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 539 540 // Verify all posts appear 541 string.contains(response_json, reply1_uri) ··· 656 " 657 658 let assert Ok(response_json) = 659 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 660 661 // Verify the reverse join through strongRef works 662 string.contains(response_json, post_uri) ··· 788 " 789 790 let assert Ok(response_json) = 791 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 792 793 // Verify we can access type-specific fields through inline fragments 794 ··· 929 " 930 931 let assert Ok(response_json) = 932 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 933 934 // Verify the response contains the DID-joined profile as a single object (not array) 935 string.contains(response_json, post_uri) ··· 1038 " 1039 1040 let assert Ok(response_json) = 1041 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 1042 1043 // Verify the response contains the DID-joined posts as a list 1044 string.contains(response_json, profile_uri) ··· 1165 " 1166 1167 let assert Ok(response_json) = 1168 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 1169 1170 // Verify all posts and their associated profiles appear 1171 string.contains(response_json, post1_uri)
··· 222 " 223 224 let assert Ok(response_json) = 225 + graphql_gleam.execute_query_with_db( 226 + db, 227 + query, 228 + "{}", 229 + Error(Nil), 230 + "", 231 + "https://plc.directory", 232 + ) 233 234 // Verify the response contains resolved join with parent URI 235 string.contains(response_json, reply_uri) ··· 318 " 319 320 let assert Ok(response_json) = 321 + graphql_gleam.execute_query_with_db( 322 + db, 323 + query, 324 + "{}", 325 + Error(Nil), 326 + "", 327 + "https://plc.directory", 328 + ) 329 330 // Verify the response contains resolved strongRef join with post URI 331 string.contains(response_json, profile_uri) ··· 432 " 433 434 let assert Ok(response_json) = 435 + graphql_gleam.execute_query_with_db( 436 + db, 437 + query, 438 + "{}", 439 + Error(Nil), 440 + "", 441 + "https://plc.directory", 442 + ) 443 444 // Verify the response contains reverse join results 445 string.contains(response_json, post_uri) ··· 556 " 557 558 let assert Ok(response_json) = 559 + graphql_gleam.execute_query_with_db( 560 + db, 561 + query, 562 + "{}", 563 + Error(Nil), 564 + "", 565 + "https://plc.directory", 566 + ) 567 568 // Verify all posts appear 569 string.contains(response_json, reply1_uri) ··· 684 " 685 686 let assert Ok(response_json) = 687 + graphql_gleam.execute_query_with_db( 688 + db, 689 + query, 690 + "{}", 691 + Error(Nil), 692 + "", 693 + "https://plc.directory", 694 + ) 695 696 // Verify the reverse join through strongRef works 697 string.contains(response_json, post_uri) ··· 823 " 824 825 let assert Ok(response_json) = 826 + graphql_gleam.execute_query_with_db( 827 + db, 828 + query, 829 + "{}", 830 + Error(Nil), 831 + "", 832 + "https://plc.directory", 833 + ) 834 835 // Verify we can access type-specific fields through inline fragments 836 ··· 971 " 972 973 let assert Ok(response_json) = 974 + graphql_gleam.execute_query_with_db( 975 + db, 976 + query, 977 + "{}", 978 + Error(Nil), 979 + "", 980 + "https://plc.directory", 981 + ) 982 983 // Verify the response contains the DID-joined profile as a single object (not array) 984 string.contains(response_json, post_uri) ··· 1087 " 1088 1089 let assert Ok(response_json) = 1090 + graphql_gleam.execute_query_with_db( 1091 + db, 1092 + query, 1093 + "{}", 1094 + Error(Nil), 1095 + "", 1096 + "https://plc.directory", 1097 + ) 1098 1099 // Verify the response contains the DID-joined posts as a list 1100 string.contains(response_json, profile_uri) ··· 1221 " 1222 1223 let assert Ok(response_json) = 1224 + graphql_gleam.execute_query_with_db( 1225 + db, 1226 + query, 1227 + "{}", 1228 + Error(Nil), 1229 + "", 1230 + "https://plc.directory", 1231 + ) 1232 1233 // Verify all posts and their associated profiles appear 1234 string.contains(response_json, post1_uri)
+3 -3
server/test/mutation_resolver_integration_test.gleam
··· 10 import gleam/list 11 import gleam/option 12 import gleeunit/should 13 - import graphql/executor 14 - import graphql/schema 15 - import graphql/value 16 import lexicon_graphql/db_schema_builder 17 import lexicon_graphql/lexicon_parser 18 import sqlight 19 20 // Helper to create a status lexicon JSON 21 fn create_status_lexicon() -> String {
··· 10 import gleam/list 11 import gleam/option 12 import gleeunit/should 13 import lexicon_graphql/db_schema_builder 14 import lexicon_graphql/lexicon_parser 15 import sqlight 16 + import swell/executor 17 + import swell/schema 18 + import swell/value 19 20 // Helper to create a status lexicon JSON 21 fn create_status_lexicon() -> String {
+40 -5
server/test/nested_join_sortby_where_test.gleam
··· 191 " 192 193 let assert Ok(response_json) = 194 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 195 196 // Verify totalCount is 5 (all statuses) 197 { ··· 327 " 328 329 let assert Ok(response_json) = 330 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 331 332 // With sortBy createdAt ASC, first:3 should return Status 1, 2, 3 (oldest first) 333 string.contains(response_json, "Status #1") ··· 438 " 439 440 let assert Ok(response_json) = 441 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 442 443 // totalCount should be 3 (only statuses containing "gleam") 444 { ··· 558 " 559 560 let assert Ok(response_json) = 561 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 562 563 // totalCount should be 3 (all rust posts) 564 { ··· 719 " 720 721 let assert Ok(response_json) = 722 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 723 724 // Should only return 1 profile (chadtmiller.com) 725 {
··· 191 " 192 193 let assert Ok(response_json) = 194 + graphql_gleam.execute_query_with_db( 195 + db, 196 + query, 197 + "{}", 198 + Error(Nil), 199 + "", 200 + "https://plc.directory", 201 + ) 202 203 // Verify totalCount is 5 (all statuses) 204 { ··· 334 " 335 336 let assert Ok(response_json) = 337 + graphql_gleam.execute_query_with_db( 338 + db, 339 + query, 340 + "{}", 341 + Error(Nil), 342 + "", 343 + "https://plc.directory", 344 + ) 345 346 // With sortBy createdAt ASC, first:3 should return Status 1, 2, 3 (oldest first) 347 string.contains(response_json, "Status #1") ··· 452 " 453 454 let assert Ok(response_json) = 455 + graphql_gleam.execute_query_with_db( 456 + db, 457 + query, 458 + "{}", 459 + Error(Nil), 460 + "", 461 + "https://plc.directory", 462 + ) 463 464 // totalCount should be 3 (only statuses containing "gleam") 465 { ··· 579 " 580 581 let assert Ok(response_json) = 582 + graphql_gleam.execute_query_with_db( 583 + db, 584 + query, 585 + "{}", 586 + Error(Nil), 587 + "", 588 + "https://plc.directory", 589 + ) 590 591 // totalCount should be 3 (all rust posts) 592 { ··· 747 " 748 749 let assert Ok(response_json) = 750 + graphql_gleam.execute_query_with_db( 751 + db, 752 + query, 753 + "{}", 754 + Error(Nil), 755 + "", 756 + "https://plc.directory", 757 + ) 758 759 // Should only return 1 profile (chadtmiller.com) 760 {
+32 -4
server/test/paginated_join_test.gleam
··· 225 " 226 227 let assert Ok(response_json) = 228 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 229 230 // Verify only 1 post is returned 231 string.contains(response_json, "\"edges\"") ··· 335 " 336 337 let assert Ok(response_json) = 338 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 339 340 // Count how many post URIs appear (should be 2) 341 let post_count = ··· 444 " 445 446 let assert Ok(response_json) = 447 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 448 449 // Count how many like URIs appear (should be 1) 450 let like_count = ··· 553 " 554 555 let assert Ok(response_json) = 556 - graphql_gleam.execute_query_with_db(db, query, "{}", Error(Nil), "", "https://plc.directory") 557 558 // All 3 posts should be returned (within default limit of 50) 559 let post_count =
··· 225 " 226 227 let assert Ok(response_json) = 228 + graphql_gleam.execute_query_with_db( 229 + db, 230 + query, 231 + "{}", 232 + Error(Nil), 233 + "", 234 + "https://plc.directory", 235 + ) 236 237 // Verify only 1 post is returned 238 string.contains(response_json, "\"edges\"") ··· 342 " 343 344 let assert Ok(response_json) = 345 + graphql_gleam.execute_query_with_db( 346 + db, 347 + query, 348 + "{}", 349 + Error(Nil), 350 + "", 351 + "https://plc.directory", 352 + ) 353 354 // Count how many post URIs appear (should be 2) 355 let post_count = ··· 458 " 459 460 let assert Ok(response_json) = 461 + graphql_gleam.execute_query_with_db( 462 + db, 463 + query, 464 + "{}", 465 + Error(Nil), 466 + "", 467 + "https://plc.directory", 468 + ) 469 470 // Count how many like URIs appear (should be 1) 471 let like_count = ··· 574 " 575 576 let assert Ok(response_json) = 577 + graphql_gleam.execute_query_with_db( 578 + db, 579 + query, 580 + "{}", 581 + Error(Nil), 582 + "", 583 + "https://plc.directory", 584 + ) 585 586 // All 3 posts should be returned (within default limit of 50) 587 let post_count =
+24 -3
server/test/reverse_join_field_resolution_test.gleam
··· 343 }" 344 345 let assert Ok(response_json) = 346 - graphql_gleam.execute_query_with_db(conn, query, "{}", Error(Nil), "", "https://plc.directory") 347 348 // Verify the response includes the gallery 349 string.contains(response_json, "Test Gallery") ··· 415 }" 416 417 let assert Ok(response_json) = 418 - graphql_gleam.execute_query_with_db(conn, query, "{}", Error(Nil), "", "https://plc.directory") 419 420 // Verify position is returned as integer, not string or null 421 { string.contains(response_json, "\"position\":42") } ··· 571 }" 572 573 let assert Ok(response_json) = 574 - graphql_gleam.execute_query_with_db(conn, query, "{}", Error(Nil), "", "https://plc.directory") 575 576 // Verify all levels of nesting work 577 string.contains(response_json, "Alice")
··· 343 }" 344 345 let assert Ok(response_json) = 346 + graphql_gleam.execute_query_with_db( 347 + conn, 348 + query, 349 + "{}", 350 + Error(Nil), 351 + "", 352 + "https://plc.directory", 353 + ) 354 355 // Verify the response includes the gallery 356 string.contains(response_json, "Test Gallery") ··· 422 }" 423 424 let assert Ok(response_json) = 425 + graphql_gleam.execute_query_with_db( 426 + conn, 427 + query, 428 + "{}", 429 + Error(Nil), 430 + "", 431 + "https://plc.directory", 432 + ) 433 434 // Verify position is returned as integer, not string or null 435 { string.contains(response_json, "\"position\":42") } ··· 585 }" 586 587 let assert Ok(response_json) = 588 + graphql_gleam.execute_query_with_db( 589 + conn, 590 + query, 591 + "{}", 592 + Error(Nil), 593 + "", 594 + "https://plc.directory", 595 + ) 596 597 // Verify all levels of nesting work 598 string.contains(response_json, "Alice")
+3 -3
server/test/sorting_enum_validation_test.gleam
··· 8 import gleam/option 9 import gleam/result 10 import gleeunit/should 11 - import graphql/executor 12 - import graphql/schema 13 - import graphql/value 14 import lexicon_graphql/dataloader 15 import lexicon_graphql/db_schema_builder 16 import lexicon_graphql/lexicon_parser 17 import lexicon_graphql/types 18 19 pub fn sorting_enum_input_types_are_unique_per_collection_test() { 20 // Test: Each collection should have its own SortFieldInput type
··· 8 import gleam/option 9 import gleam/result 10 import gleeunit/should 11 import lexicon_graphql/dataloader 12 import lexicon_graphql/db_schema_builder 13 import lexicon_graphql/lexicon_parser 14 import lexicon_graphql/types 15 + import swell/executor 16 + import swell/schema 17 + import swell/value 18 19 pub fn sorting_enum_input_types_are_unique_per_collection_test() { 20 // Test: Each collection should have its own SortFieldInput type
+1 -1
server/test/where_edge_cases_test.gleam
··· 7 import gleam/string 8 import gleeunit 9 import gleeunit/should 10 - import graphql/value 11 import lexicon_graphql/where_input 12 import sqlight 13 import where_clause 14 15 pub fn main() {
··· 7 import gleam/string 8 import gleeunit 9 import gleeunit/should 10 import lexicon_graphql/where_input 11 import sqlight 12 + import swell/value 13 import where_clause 14 15 pub fn main() {