Mirror: The spec-compliant minimum of client-side GraphQL.

Add support for executable descriptions (#59)

* Add support for executable descriptions

* Add changeset

* Cleanup

authored by Jovi De Croock and committed by GitHub 2c03c758 58290cf2

Changed files
+335 -16
.changeset
src
+5
.changeset/gold-apricots-report.md
··· 1 + --- 2 + "@0no-co/graphql.web": minor 3 + --- 4 + 5 + Add support for executable definitions as defined in https://github.com/graphql/graphql-spec/pull/1170
+278
src/__tests__/description.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parse } from '../parser'; 3 + import { print } from '../printer'; 4 + import type { 5 + OperationDefinitionNode, 6 + VariableDefinitionNode, 7 + FragmentDefinitionNode, 8 + } from '../ast'; 9 + 10 + describe('GraphQL descriptions', () => { 11 + describe('OperationDefinition descriptions', () => { 12 + it('parses operation with description', () => { 13 + const source = ` 14 + """ 15 + Request the current status of a time machine and its operator. 16 + """ 17 + query GetTimeMachineStatus { 18 + timeMachine { 19 + id 20 + status 21 + } 22 + } 23 + `; 24 + 25 + const doc = parse(source, { noLocation: true }); 26 + const operation = doc.definitions[0] as OperationDefinitionNode; 27 + 28 + expect(operation.description).toBeDefined(); 29 + expect(operation.description?.value).toBe( 30 + 'Request the current status of a time machine and its operator.' 31 + ); 32 + expect(operation.description?.block).toBe(true); 33 + }); 34 + 35 + it('parses operation with single-line description', () => { 36 + const source = ` 37 + "Simple query description" 38 + query SimpleQuery { 39 + field 40 + } 41 + `; 42 + 43 + const doc = parse(source, { noLocation: true }); 44 + const operation = doc.definitions[0] as OperationDefinitionNode; 45 + 46 + expect(operation.description).toBeDefined(); 47 + expect(operation.description?.value).toBe('Simple query description'); 48 + expect(operation.description?.block).toBe(false); 49 + }); 50 + 51 + it('does not allow description on anonymous operations', () => { 52 + const source = ` 53 + "This should fail" 54 + { 55 + field 56 + } 57 + `; 58 + 59 + expect(() => parse(source)).toThrow(); 60 + }); 61 + 62 + it('parses mutation with description', () => { 63 + const source = ` 64 + """ 65 + Create a new time machine entry. 66 + """ 67 + mutation CreateTimeMachine($input: TimeMachineInput!) { 68 + createTimeMachine(input: $input) { 69 + id 70 + } 71 + } 72 + `; 73 + 74 + const doc = parse(source, { noLocation: true }); 75 + const operation = doc.definitions[0] as OperationDefinitionNode; 76 + 77 + expect(operation.description).toBeDefined(); 78 + expect(operation.description?.value).toBe('Create a new time machine entry.'); 79 + }); 80 + }); 81 + 82 + describe('VariableDefinition descriptions', () => { 83 + it('parses variable with description', () => { 84 + const source = ` 85 + query GetTimeMachineStatus( 86 + "The unique serial number of the time machine to inspect." 87 + $machineId: ID! 88 + 89 + """ 90 + The year to check the status for. 91 + **Warning:** certain years may trigger an anomaly in the space-time continuum. 92 + """ 93 + $year: Int 94 + ) { 95 + timeMachine(id: $machineId) { 96 + status(year: $year) 97 + } 98 + } 99 + `; 100 + 101 + const doc = parse(source, { noLocation: true }); 102 + const operation = doc.definitions[0] as OperationDefinitionNode; 103 + const variables = operation.variableDefinitions as VariableDefinitionNode[]; 104 + 105 + expect(variables[0].description).toBeDefined(); 106 + expect(variables[0].description?.value).toBe( 107 + 'The unique serial number of the time machine to inspect.' 108 + ); 109 + expect(variables[0].description?.block).toBe(false); 110 + 111 + expect(variables[1].description).toBeDefined(); 112 + expect(variables[1].description?.value).toBe( 113 + 'The year to check the status for.\n**Warning:** certain years may trigger an anomaly in the space-time continuum.' 114 + ); 115 + expect(variables[1].description?.block).toBe(true); 116 + }); 117 + 118 + it('parses mixed variables with and without descriptions', () => { 119 + const source = ` 120 + query Mixed( 121 + "Described variable" 122 + $described: String 123 + $undescribed: Int 124 + ) { 125 + field 126 + } 127 + `; 128 + 129 + const doc = parse(source, { noLocation: true }); 130 + const operation = doc.definitions[0] as OperationDefinitionNode; 131 + const variables = operation.variableDefinitions as VariableDefinitionNode[]; 132 + 133 + expect(variables[0].description).toBeDefined(); 134 + expect(variables[0].description?.value).toBe('Described variable'); 135 + expect(variables[1].description).toBeUndefined(); 136 + }); 137 + }); 138 + 139 + describe('FragmentDefinition descriptions', () => { 140 + it('parses fragment with description', () => { 141 + const source = ` 142 + "Time machine details." 143 + fragment TimeMachineDetails on TimeMachine { 144 + id 145 + model 146 + lastMaintenance 147 + } 148 + `; 149 + 150 + const doc = parse(source, { noLocation: true }); 151 + const fragment = doc.definitions[0] as FragmentDefinitionNode; 152 + 153 + expect(fragment.description).toBeDefined(); 154 + expect(fragment.description?.value).toBe('Time machine details.'); 155 + expect(fragment.description?.block).toBe(false); 156 + }); 157 + 158 + it('parses fragment with block description', () => { 159 + const source = ` 160 + """ 161 + Comprehensive time machine information 162 + including maintenance history and operational status. 163 + """ 164 + fragment FullTimeMachineInfo on TimeMachine { 165 + id 166 + model 167 + lastMaintenance 168 + operationalStatus 169 + } 170 + `; 171 + 172 + const doc = parse(source, { noLocation: true }); 173 + const fragment = doc.definitions[0] as FragmentDefinitionNode; 174 + 175 + expect(fragment.description).toBeDefined(); 176 + expect(fragment.description?.value).toBe( 177 + 'Comprehensive time machine information\nincluding maintenance history and operational status.' 178 + ); 179 + expect(fragment.description?.block).toBe(true); 180 + }); 181 + }); 182 + 183 + describe('print with descriptions', () => { 184 + it('prints operation description correctly', () => { 185 + const source = `""" 186 + Request the current status of a time machine and its operator. 187 + """ 188 + query GetTimeMachineStatus { 189 + timeMachine { 190 + id 191 + } 192 + }`; 193 + 194 + const doc = parse(source, { noLocation: true }); 195 + const printed = print(doc); 196 + 197 + expect(printed).toContain('"""'); 198 + expect(printed).toContain('Request the current status of a time machine and its operator.'); 199 + }); 200 + 201 + it('prints variable descriptions correctly', () => { 202 + const source = `query GetStatus( 203 + "Machine ID" 204 + $id: ID! 205 + ) { 206 + field 207 + }`; 208 + 209 + const doc = parse(source, { noLocation: true }); 210 + const printed = print(doc); 211 + 212 + expect(printed).toContain('"Machine ID"'); 213 + }); 214 + 215 + it('prints fragment description correctly', () => { 216 + const source = `"Details fragment" 217 + fragment Details on Type { 218 + field 219 + }`; 220 + 221 + const doc = parse(source, { noLocation: true }); 222 + const printed = print(doc); 223 + 224 + expect(printed).toContain('"Details fragment"'); 225 + }); 226 + }); 227 + 228 + describe('roundtrip parsing and printing', () => { 229 + it('maintains descriptions through parse and print cycle', () => { 230 + const source = `""" 231 + Request the current status of a time machine and its operator. 232 + """ 233 + query GetTimeMachineStatus( 234 + "The unique serial number of the time machine to inspect." 235 + $machineId: ID! 236 + 237 + """ 238 + The year to check the status for. 239 + **Warning:** certain years may trigger an anomaly in the space-time continuum. 240 + """ 241 + $year: Int 242 + ) { 243 + timeMachine(id: $machineId) { 244 + ...TimeMachineDetails 245 + operator { 246 + name 247 + licenseLevel 248 + } 249 + status(year: $year) 250 + } 251 + } 252 + 253 + "Time machine details." 254 + fragment TimeMachineDetails on TimeMachine { 255 + id 256 + model 257 + lastMaintenance 258 + }`; 259 + 260 + const doc = parse(source, { noLocation: true }); 261 + const printed = print(doc); 262 + const reparsed = parse(printed, { noLocation: true }); 263 + 264 + const operation = doc.definitions[0] as OperationDefinitionNode; 265 + const reparsedOperation = reparsed.definitions[0] as OperationDefinitionNode; 266 + 267 + // The printed/reparsed cycle may have slightly different formatting but same content 268 + expect(reparsedOperation.description?.value?.trim()).toBe( 269 + operation.description?.value?.trim() 270 + ); 271 + 272 + const fragment = doc.definitions[1] as FragmentDefinitionNode; 273 + const reparsedFragment = reparsed.definitions[1] as FragmentDefinitionNode; 274 + 275 + expect(reparsedFragment.description?.value).toBe(fragment.description?.value); 276 + }); 277 + }); 278 + });
+6 -3
src/ast.ts
··· 103 103 >; 104 104 105 105 export type OperationDefinitionNode = Or< 106 - GraphQL.OperationDefinitionNode, 106 + GraphQL.OperationDefinitionNode & { description?: StringValueNode }, 107 107 { 108 108 readonly kind: Kind.OPERATION_DEFINITION; 109 109 readonly operation: OperationTypeNode; 110 110 readonly name?: NameNode; 111 + readonly description?: StringValueNode; 111 112 readonly variableDefinitions?: ReadonlyArray<VariableDefinitionNode>; 112 113 readonly directives?: ReadonlyArray<DirectiveNode>; 113 114 readonly selectionSet: SelectionSetNode; ··· 116 117 >; 117 118 118 119 export type VariableDefinitionNode = Or< 119 - GraphQL.VariableDefinitionNode, 120 + GraphQL.VariableDefinitionNode & { description?: StringValueNode }, 120 121 { 121 122 readonly kind: Kind.VARIABLE_DEFINITION; 122 123 readonly variable: VariableNode; 123 124 readonly type: TypeNode; 124 125 readonly defaultValue?: ConstValueNode; 126 + readonly description?: StringValueNode; 125 127 readonly directives?: ReadonlyArray<ConstDirectiveNode>; 126 128 readonly loc?: Location; 127 129 } ··· 205 207 >; 206 208 207 209 export type FragmentDefinitionNode = Or< 208 - GraphQL.FragmentDefinitionNode, 210 + GraphQL.FragmentDefinitionNode & { description?: StringValueNode }, 209 211 { 210 212 readonly kind: Kind.FRAGMENT_DEFINITION; 211 213 readonly name: NameNode; 214 + readonly description?: StringValueNode; 212 215 readonly typeCondition: NamedTypeNode; 213 216 readonly directives?: ReadonlyArray<DirectiveNode>; 214 217 readonly selectionSet: SelectionSetNode;
+29 -7
src/parser.ts
··· 421 421 idx++; 422 422 ignored(); 423 423 do { 424 + let _description: ast.StringValueNode | undefined; 425 + if (input.charCodeAt(idx) === 34 /*'"'*/) { 426 + _description = value(true) as ast.StringValueNode; 427 + } 424 428 if (input.charCodeAt(idx++) !== 36 /*'$'*/) throw error('Variable'); 425 429 const name = nameNode(); 426 430 if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('VariableDefinition'); ··· 433 437 _defaultValue = value(true); 434 438 } 435 439 ignored(); 436 - vars.push({ 440 + const varDef: ast.VariableDefinitionNode = { 437 441 kind: 'VariableDefinition' as Kind.VARIABLE_DEFINITION, 438 442 variable: { 439 443 kind: 'Variable' as Kind.VARIABLE, ··· 442 446 type: _type, 443 447 defaultValue: _defaultValue, 444 448 directives: directives(true), 445 - }); 449 + }; 450 + if (_description) { 451 + varDef.description = _description; 452 + } 453 + vars.push(varDef); 446 454 } while (input.charCodeAt(idx) !== 41 /*')'*/); 447 455 idx++; 448 456 ignored(); ··· 450 458 } 451 459 } 452 460 453 - function fragmentDefinition(): ast.FragmentDefinitionNode { 461 + function fragmentDefinition(description?: ast.StringValueNode): ast.FragmentDefinitionNode { 454 462 const name = nameNode(); 455 463 if (input.charCodeAt(idx++) !== 111 /*'o'*/ || input.charCodeAt(idx++) !== 110 /*'n'*/) 456 464 throw error('FragmentDefinition'); 457 465 ignored(); 458 - return { 466 + const fragDef: ast.FragmentDefinitionNode = { 459 467 kind: 'FragmentDefinition' as Kind.FRAGMENT_DEFINITION, 460 468 name, 461 469 typeCondition: { ··· 465 473 directives: directives(false), 466 474 selectionSet: selectionSetStart(), 467 475 }; 476 + if (description) { 477 + fragDef.description = description; 478 + } 479 + return fragDef; 468 480 } 469 481 470 482 function definitions(): ast.DefinitionNode[] { 471 483 const _definitions: ast.ExecutableDefinitionNode[] = []; 472 484 do { 485 + let _description: ast.StringValueNode | undefined; 486 + if (input.charCodeAt(idx) === 34 /*'"'*/) { 487 + _description = value(true) as ast.StringValueNode; 488 + } 473 489 if (input.charCodeAt(idx) === 123 /*'{'*/) { 490 + // Anonymous operations can't have descriptions 491 + if (_description) throw error('Document'); 474 492 idx++; 475 493 ignored(); 476 494 _definitions.push({ ··· 485 503 const definition = name(); 486 504 switch (definition) { 487 505 case 'fragment': 488 - _definitions.push(fragmentDefinition()); 506 + _definitions.push(fragmentDefinition(_description)); 489 507 break; 490 508 case 'query': 491 509 case 'mutation': ··· 499 517 ) { 500 518 name = nameNode(); 501 519 } 502 - _definitions.push({ 520 + const opDef: ast.OperationDefinitionNode = { 503 521 kind: 'OperationDefinition' as Kind.OPERATION_DEFINITION, 504 522 operation: definition as OperationTypeNode, 505 523 name, 506 524 variableDefinitions: variableDefinitions(), 507 525 directives: directives(false), 508 526 selectionSet: selectionSetStart(), 509 - }); 527 + }; 528 + if (_description) { 529 + opDef.description = _description; 530 + } 531 + _definitions.push(opDef); 510 532 break; 511 533 default: 512 534 throw error('Document');
+17 -6
src/printer.ts
··· 49 49 50 50 const nodes = { 51 51 OperationDefinition(node: OperationDefinitionNode): string { 52 - let out: string = node.operation; 52 + let out: string = ''; 53 + if (node.description) { 54 + out += nodes.StringValue(node.description) + '\n'; 55 + } 56 + out += node.operation; 53 57 if (node.name) out += ' ' + node.name.value; 54 58 if (node.variableDefinitions && node.variableDefinitions.length) { 55 59 if (!node.name) out += ' '; ··· 57 61 } 58 62 if (node.directives && node.directives.length) 59 63 out += ' ' + mapJoin(node.directives, ' ', nodes.Directive); 60 - return out !== 'query' 61 - ? out + ' ' + nodes.SelectionSet(node.selectionSet) 62 - : nodes.SelectionSet(node.selectionSet); 64 + const selectionSet = nodes.SelectionSet(node.selectionSet); 65 + return out !== 'query' ? out + ' ' + selectionSet : selectionSet; 63 66 }, 64 67 VariableDefinition(node: VariableDefinitionNode): string { 65 - let out = nodes.Variable!(node.variable) + ': ' + _print(node.type); 68 + let out = ''; 69 + if (node.description) { 70 + out += nodes.StringValue(node.description) + ' '; 71 + } 72 + out += nodes.Variable!(node.variable) + ': ' + _print(node.type); 66 73 if (node.defaultValue) out += ' = ' + _print(node.defaultValue); 67 74 if (node.directives && node.directives.length) 68 75 out += ' ' + mapJoin(node.directives, ' ', nodes.Directive); ··· 152 159 return out; 153 160 }, 154 161 FragmentDefinition(node: FragmentDefinitionNode): string { 155 - let out = 'fragment ' + node.name.value; 162 + let out = ''; 163 + if (node.description) { 164 + out += nodes.StringValue(node.description) + '\n'; 165 + } 166 + out += 'fragment ' + node.name.value; 156 167 out += ' on ' + node.typeCondition.name.value; 157 168 if (node.directives && node.directives.length) 158 169 out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);