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

Add support for executable descriptions (#59)

* Add support for executable descriptions

* Add changeset

* Cleanup

Changed files
+335 -16
.changeset
src
+5
.changeset/gold-apricots-report.md
···
+
---
+
"@0no-co/graphql.web": minor
+
---
+
+
Add support for executable definitions as defined in https://github.com/graphql/graphql-spec/pull/1170
+278
src/__tests__/description.test.ts
···
+
import { describe, it, expect } from 'vitest';
+
import { parse } from '../parser';
+
import { print } from '../printer';
+
import type {
+
OperationDefinitionNode,
+
VariableDefinitionNode,
+
FragmentDefinitionNode,
+
} from '../ast';
+
+
describe('GraphQL descriptions', () => {
+
describe('OperationDefinition descriptions', () => {
+
it('parses operation with description', () => {
+
const source = `
+
"""
+
Request the current status of a time machine and its operator.
+
"""
+
query GetTimeMachineStatus {
+
timeMachine {
+
id
+
status
+
}
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const operation = doc.definitions[0] as OperationDefinitionNode;
+
+
expect(operation.description).toBeDefined();
+
expect(operation.description?.value).toBe(
+
'Request the current status of a time machine and its operator.'
+
);
+
expect(operation.description?.block).toBe(true);
+
});
+
+
it('parses operation with single-line description', () => {
+
const source = `
+
"Simple query description"
+
query SimpleQuery {
+
field
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const operation = doc.definitions[0] as OperationDefinitionNode;
+
+
expect(operation.description).toBeDefined();
+
expect(operation.description?.value).toBe('Simple query description');
+
expect(operation.description?.block).toBe(false);
+
});
+
+
it('does not allow description on anonymous operations', () => {
+
const source = `
+
"This should fail"
+
{
+
field
+
}
+
`;
+
+
expect(() => parse(source)).toThrow();
+
});
+
+
it('parses mutation with description', () => {
+
const source = `
+
"""
+
Create a new time machine entry.
+
"""
+
mutation CreateTimeMachine($input: TimeMachineInput!) {
+
createTimeMachine(input: $input) {
+
id
+
}
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const operation = doc.definitions[0] as OperationDefinitionNode;
+
+
expect(operation.description).toBeDefined();
+
expect(operation.description?.value).toBe('Create a new time machine entry.');
+
});
+
});
+
+
describe('VariableDefinition descriptions', () => {
+
it('parses variable with description', () => {
+
const source = `
+
query GetTimeMachineStatus(
+
"The unique serial number of the time machine to inspect."
+
$machineId: ID!
+
+
"""
+
The year to check the status for.
+
**Warning:** certain years may trigger an anomaly in the space-time continuum.
+
"""
+
$year: Int
+
) {
+
timeMachine(id: $machineId) {
+
status(year: $year)
+
}
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const operation = doc.definitions[0] as OperationDefinitionNode;
+
const variables = operation.variableDefinitions as VariableDefinitionNode[];
+
+
expect(variables[0].description).toBeDefined();
+
expect(variables[0].description?.value).toBe(
+
'The unique serial number of the time machine to inspect.'
+
);
+
expect(variables[0].description?.block).toBe(false);
+
+
expect(variables[1].description).toBeDefined();
+
expect(variables[1].description?.value).toBe(
+
'The year to check the status for.\n**Warning:** certain years may trigger an anomaly in the space-time continuum.'
+
);
+
expect(variables[1].description?.block).toBe(true);
+
});
+
+
it('parses mixed variables with and without descriptions', () => {
+
const source = `
+
query Mixed(
+
"Described variable"
+
$described: String
+
$undescribed: Int
+
) {
+
field
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const operation = doc.definitions[0] as OperationDefinitionNode;
+
const variables = operation.variableDefinitions as VariableDefinitionNode[];
+
+
expect(variables[0].description).toBeDefined();
+
expect(variables[0].description?.value).toBe('Described variable');
+
expect(variables[1].description).toBeUndefined();
+
});
+
});
+
+
describe('FragmentDefinition descriptions', () => {
+
it('parses fragment with description', () => {
+
const source = `
+
"Time machine details."
+
fragment TimeMachineDetails on TimeMachine {
+
id
+
model
+
lastMaintenance
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const fragment = doc.definitions[0] as FragmentDefinitionNode;
+
+
expect(fragment.description).toBeDefined();
+
expect(fragment.description?.value).toBe('Time machine details.');
+
expect(fragment.description?.block).toBe(false);
+
});
+
+
it('parses fragment with block description', () => {
+
const source = `
+
"""
+
Comprehensive time machine information
+
including maintenance history and operational status.
+
"""
+
fragment FullTimeMachineInfo on TimeMachine {
+
id
+
model
+
lastMaintenance
+
operationalStatus
+
}
+
`;
+
+
const doc = parse(source, { noLocation: true });
+
const fragment = doc.definitions[0] as FragmentDefinitionNode;
+
+
expect(fragment.description).toBeDefined();
+
expect(fragment.description?.value).toBe(
+
'Comprehensive time machine information\nincluding maintenance history and operational status.'
+
);
+
expect(fragment.description?.block).toBe(true);
+
});
+
});
+
+
describe('print with descriptions', () => {
+
it('prints operation description correctly', () => {
+
const source = `"""
+
Request the current status of a time machine and its operator.
+
"""
+
query GetTimeMachineStatus {
+
timeMachine {
+
id
+
}
+
}`;
+
+
const doc = parse(source, { noLocation: true });
+
const printed = print(doc);
+
+
expect(printed).toContain('"""');
+
expect(printed).toContain('Request the current status of a time machine and its operator.');
+
});
+
+
it('prints variable descriptions correctly', () => {
+
const source = `query GetStatus(
+
"Machine ID"
+
$id: ID!
+
) {
+
field
+
}`;
+
+
const doc = parse(source, { noLocation: true });
+
const printed = print(doc);
+
+
expect(printed).toContain('"Machine ID"');
+
});
+
+
it('prints fragment description correctly', () => {
+
const source = `"Details fragment"
+
fragment Details on Type {
+
field
+
}`;
+
+
const doc = parse(source, { noLocation: true });
+
const printed = print(doc);
+
+
expect(printed).toContain('"Details fragment"');
+
});
+
});
+
+
describe('roundtrip parsing and printing', () => {
+
it('maintains descriptions through parse and print cycle', () => {
+
const source = `"""
+
Request the current status of a time machine and its operator.
+
"""
+
query GetTimeMachineStatus(
+
"The unique serial number of the time machine to inspect."
+
$machineId: ID!
+
+
"""
+
The year to check the status for.
+
**Warning:** certain years may trigger an anomaly in the space-time continuum.
+
"""
+
$year: Int
+
) {
+
timeMachine(id: $machineId) {
+
...TimeMachineDetails
+
operator {
+
name
+
licenseLevel
+
}
+
status(year: $year)
+
}
+
}
+
+
"Time machine details."
+
fragment TimeMachineDetails on TimeMachine {
+
id
+
model
+
lastMaintenance
+
}`;
+
+
const doc = parse(source, { noLocation: true });
+
const printed = print(doc);
+
const reparsed = parse(printed, { noLocation: true });
+
+
const operation = doc.definitions[0] as OperationDefinitionNode;
+
const reparsedOperation = reparsed.definitions[0] as OperationDefinitionNode;
+
+
// The printed/reparsed cycle may have slightly different formatting but same content
+
expect(reparsedOperation.description?.value?.trim()).toBe(
+
operation.description?.value?.trim()
+
);
+
+
const fragment = doc.definitions[1] as FragmentDefinitionNode;
+
const reparsedFragment = reparsed.definitions[1] as FragmentDefinitionNode;
+
+
expect(reparsedFragment.description?.value).toBe(fragment.description?.value);
+
});
+
});
+
});
+6 -3
src/ast.ts
···
>;
export type OperationDefinitionNode = Or<
-
GraphQL.OperationDefinitionNode,
+
GraphQL.OperationDefinitionNode & { description?: StringValueNode },
{
readonly kind: Kind.OPERATION_DEFINITION;
readonly operation: OperationTypeNode;
readonly name?: NameNode;
+
readonly description?: StringValueNode;
readonly variableDefinitions?: ReadonlyArray<VariableDefinitionNode>;
readonly directives?: ReadonlyArray<DirectiveNode>;
readonly selectionSet: SelectionSetNode;
···
>;
export type VariableDefinitionNode = Or<
-
GraphQL.VariableDefinitionNode,
+
GraphQL.VariableDefinitionNode & { description?: StringValueNode },
{
readonly kind: Kind.VARIABLE_DEFINITION;
readonly variable: VariableNode;
readonly type: TypeNode;
readonly defaultValue?: ConstValueNode;
+
readonly description?: StringValueNode;
readonly directives?: ReadonlyArray<ConstDirectiveNode>;
readonly loc?: Location;
}
···
>;
export type FragmentDefinitionNode = Or<
-
GraphQL.FragmentDefinitionNode,
+
GraphQL.FragmentDefinitionNode & { description?: StringValueNode },
{
readonly kind: Kind.FRAGMENT_DEFINITION;
readonly name: NameNode;
+
readonly description?: StringValueNode;
readonly typeCondition: NamedTypeNode;
readonly directives?: ReadonlyArray<DirectiveNode>;
readonly selectionSet: SelectionSetNode;
+29 -7
src/parser.ts
···
idx++;
ignored();
do {
+
let _description: ast.StringValueNode | undefined;
+
if (input.charCodeAt(idx) === 34 /*'"'*/) {
+
_description = value(true) as ast.StringValueNode;
+
}
if (input.charCodeAt(idx++) !== 36 /*'$'*/) throw error('Variable');
const name = nameNode();
if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('VariableDefinition');
···
_defaultValue = value(true);
}
ignored();
-
vars.push({
+
const varDef: ast.VariableDefinitionNode = {
kind: 'VariableDefinition' as Kind.VARIABLE_DEFINITION,
variable: {
kind: 'Variable' as Kind.VARIABLE,
···
type: _type,
defaultValue: _defaultValue,
directives: directives(true),
-
});
+
};
+
if (_description) {
+
varDef.description = _description;
+
}
+
vars.push(varDef);
} while (input.charCodeAt(idx) !== 41 /*')'*/);
idx++;
ignored();
···
}
}
-
function fragmentDefinition(): ast.FragmentDefinitionNode {
+
function fragmentDefinition(description?: ast.StringValueNode): ast.FragmentDefinitionNode {
const name = nameNode();
if (input.charCodeAt(idx++) !== 111 /*'o'*/ || input.charCodeAt(idx++) !== 110 /*'n'*/)
throw error('FragmentDefinition');
ignored();
-
return {
+
const fragDef: ast.FragmentDefinitionNode = {
kind: 'FragmentDefinition' as Kind.FRAGMENT_DEFINITION,
name,
typeCondition: {
···
directives: directives(false),
selectionSet: selectionSetStart(),
};
+
if (description) {
+
fragDef.description = description;
+
}
+
return fragDef;
}
function definitions(): ast.DefinitionNode[] {
const _definitions: ast.ExecutableDefinitionNode[] = [];
do {
+
let _description: ast.StringValueNode | undefined;
+
if (input.charCodeAt(idx) === 34 /*'"'*/) {
+
_description = value(true) as ast.StringValueNode;
+
}
if (input.charCodeAt(idx) === 123 /*'{'*/) {
+
// Anonymous operations can't have descriptions
+
if (_description) throw error('Document');
idx++;
ignored();
_definitions.push({
···
const definition = name();
switch (definition) {
case 'fragment':
-
_definitions.push(fragmentDefinition());
+
_definitions.push(fragmentDefinition(_description));
break;
case 'query':
case 'mutation':
···
) {
name = nameNode();
}
-
_definitions.push({
+
const opDef: ast.OperationDefinitionNode = {
kind: 'OperationDefinition' as Kind.OPERATION_DEFINITION,
operation: definition as OperationTypeNode,
name,
variableDefinitions: variableDefinitions(),
directives: directives(false),
selectionSet: selectionSetStart(),
-
});
+
};
+
if (_description) {
+
opDef.description = _description;
+
}
+
_definitions.push(opDef);
break;
default:
throw error('Document');
+17 -6
src/printer.ts
···
const nodes = {
OperationDefinition(node: OperationDefinitionNode): string {
-
let out: string = node.operation;
+
let out: string = '';
+
if (node.description) {
+
out += nodes.StringValue(node.description) + '\n';
+
}
+
out += node.operation;
if (node.name) out += ' ' + node.name.value;
if (node.variableDefinitions && node.variableDefinitions.length) {
if (!node.name) out += ' ';
···
}
if (node.directives && node.directives.length)
out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);
-
return out !== 'query'
-
? out + ' ' + nodes.SelectionSet(node.selectionSet)
-
: nodes.SelectionSet(node.selectionSet);
+
const selectionSet = nodes.SelectionSet(node.selectionSet);
+
return out !== 'query' ? out + ' ' + selectionSet : selectionSet;
},
VariableDefinition(node: VariableDefinitionNode): string {
-
let out = nodes.Variable!(node.variable) + ': ' + _print(node.type);
+
let out = '';
+
if (node.description) {
+
out += nodes.StringValue(node.description) + ' ';
+
}
+
out += nodes.Variable!(node.variable) + ': ' + _print(node.type);
if (node.defaultValue) out += ' = ' + _print(node.defaultValue);
if (node.directives && node.directives.length)
out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);
···
return out;
},
FragmentDefinition(node: FragmentDefinitionNode): string {
-
let out = 'fragment ' + node.name.value;
+
let out = '';
+
if (node.description) {
+
out += nodes.StringValue(node.description) + '\n';
+
}
+
out += 'fragment ' + node.name.value;
out += ' on ' + node.typeCondition.name.value;
if (node.directives && node.directives.length)
out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);