diff --git a/.cspell/cspell-dict.txt b/.cspell/cspell-dict.txt index b7e7c5fab..99a37550a 100644 --- a/.cspell/cspell-dict.txt +++ b/.cspell/cspell-dict.txt @@ -61,6 +61,7 @@ Didnt diretive doesn Dont +ebnf effecient elimiate elts diff --git a/internals-js/package.json b/internals-js/package.json index f97625cc6..86562d5e8 100644 --- a/internals-js/package.json +++ b/internals-js/package.json @@ -23,9 +23,10 @@ "node": ">=14.15.0" }, "dependencies": { + "@types/uuid": "^9.0.0", "chalk": "^4.1.0", + "ebnf": "^1.9.1", "js-levenshtein": "^1.1.6", - "@types/uuid": "^9.0.0", "uuid": "^9.0.0" }, "publishConfig": { diff --git a/internals-js/src/specs/__tests__/sourceSpec.test.ts b/internals-js/src/specs/__tests__/sourceSpec.test.ts index 3eccf8dd7..e9ce7c18c 100644 --- a/internals-js/src/specs/__tests__/sourceSpec.test.ts +++ b/internals-js/src/specs/__tests__/sourceSpec.test.ts @@ -1,7 +1,309 @@ -import { sourceIdentity } from '../index'; +import { + sourceIdentity, + parseJSONSelection, + getSelectionOutputShape, + parseURLPathTemplate, + getURLPathTemplateVars, +} from '../index'; describe('SourceSpecDefinition', () => { it('should export expected identity URL', () => { expect(sourceIdentity).toBe('https://specs.apollo.dev/source'); }); }); + +function parseSelectionExpectingNoErrors(selection: string) { + const ast = parseJSONSelection(selection)!; + expect(ast.errors).toEqual([]); + return ast; +} + +describe('parseJSONSelection', () => { + it('parses simple selections', () => { + expect(parseSelectionExpectingNoErrors('a')!.type).toBe('Selection'); + expect(parseSelectionExpectingNoErrors('a b')!.type).toBe('Selection'); + expect(parseSelectionExpectingNoErrors('a b { c }')!.type).toBe('Selection'); + expect(parseSelectionExpectingNoErrors('.a')!.type).toBe('Selection'); + expect(parseSelectionExpectingNoErrors('.a.b.c')!.type).toBe('Selection'); + }); + + const complexSelection = ` + # Basic field selection. + foo + + # Similar to a GraphQL alias with a subselection. + barAlias: bar { x y z } + + # Similar to a GraphQL alias without a subselection, but allowing for JSON + # properties that are not valid GraphQL Name identifiers. + quotedAlias: "string literal" { nested stuff } + + # Apply a subselection to the result of extracting .foo.bar, and alias it. + pathAlias: .foo.bar { a b c } + + # Nest various fields under a new key (group). + group: { foo baz: bar { x y z } } + + # Get the first event from events and apply a selection and an alias to it. + firstEvent: .events.0 { id description } + + # Apply the { nested stuff } selection to any remaining properties and alias + # the result as starAlias. Note that any * selection must appear last in the + # sequence of named selections, and will be typed as JSON regardless of what + # is subselected, because the field names are unknown. + starAlias: * { nested stuff } + `; + // TODO Improve error message when other named selections accidentally follow + // a * selection. + + it('parses a multiline selection with comments', () => { + expect(parseSelectionExpectingNoErrors(complexSelection)!.type).toBe('Selection'); + }); + + describe('getSelectionOutputShape', () => { + it('returns the correct output shape for a simple selection', () => { + const ast = parseSelectionExpectingNoErrors('a'); + if (!ast) { + throw new Error('Generic parse failure for a'); + } + expect(getSelectionOutputShape(ast)).toEqual({ + a: 'JSON', + }); + }); + + it('returns the correct output shape for a complex selection', () => { + const ast = parseSelectionExpectingNoErrors(complexSelection); + if (!ast) { + throw new Error('Generic parse failure for ' + complexSelection); + } + expect(getSelectionOutputShape(ast)).toEqual({ + foo: 'JSON', + barAlias: { + x: 'JSON', + y: 'JSON', + z: 'JSON', + }, + quotedAlias: { + nested: 'JSON', + stuff: 'JSON', + }, + pathAlias: { + a: 'JSON', + b: 'JSON', + c: 'JSON', + }, + group: { + foo: 'JSON', + baz: { + x: 'JSON', + y: 'JSON', + z: 'JSON', + }, + }, + starAlias: 'JSON', + firstEvent: { + id: 'JSON', + description: 'JSON', + }, + }); + }); + + it('returns the correct output shape for a selection with nested fields', () => { + const ast = parseSelectionExpectingNoErrors(` + a + b { c d } + e { f { g h } } + i { j { k l } } + m { n o { p q } } + # stray comment + r { s t { u v } } + w { x { y z } } + `); + + if (!ast) { + throw new Error('Generic parse failure for alphabet soup'); + } + + expect(getSelectionOutputShape(ast)).toEqual({ + a: 'JSON', + b: { + c: 'JSON', + d: 'JSON', + }, + e: { + f: { + g: 'JSON', + h: 'JSON', + }, + }, + i: { + j: { + k: 'JSON', + l: 'JSON', + }, + }, + m: { + n: 'JSON', + o: { + p: 'JSON', + q: 'JSON', + }, + }, + r: { + s: 'JSON', + t: { + u: 'JSON', + v: 'JSON', + }, + }, + w: { + x: { + y: 'JSON', + z: 'JSON', + }, + }, + }); + }); + }); +}); + +describe('parseURLPathTemplate', () => { + it('allows an empty path', () => { + const template = '/'; + const ast = parseURLPathTemplate(template); + if (!ast) { + throw new Error('Generic parse failure for ' + template); + } + expect(ast.errors).toEqual([]); + expect(getURLPathTemplateVars(ast)).toEqual({}); + }); + + it('allows query params only', () => { + const template = '/?param={param}&other={other}'; + const ast = parseURLPathTemplate(template); + if (!ast) { + throw new Error('Generic parse failure for ' + template); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(Object.keys(vars).sort()).toEqual([ + 'other', + 'param', + ]); + }); + + it('allows empty query parameters after a /?', () => { + const template = '/?'; + const ast = parseURLPathTemplate(template); + if (!ast) { + throw new Error('Generic parse failure for ' + template); + } + expect(ast.errors).toEqual([]); + expect(getURLPathTemplateVars(ast)).toEqual({}); + }); + + it('allows valueless keys in query parameters', () => { + const template = '/?a&b=&c&d=&e'; + const ast = parseURLPathTemplate(template); + if (!ast) { + throw new Error('Generic parse failure for ' + template); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(Object.keys(vars).sort()).toEqual([]); + }); + + it.each([ + '/users/{userId}/posts/{postId}', + '/users/{userId}/posts/{postId}/', + '/users/{userId}/posts/{postId}/junk', + ] as const)('parses path-only templates with variables: %s', pathTemplate => { + const ast = parseURLPathTemplate(pathTemplate); + if (!ast) { + throw new Error('Generic parse failure for ' + pathTemplate); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(Object.keys(vars).sort()).toEqual([ + 'postId', + 'userId', + ]); + }); + + it.each([ + '/users/{user.id}/posts/{post.id}', + '/users/{user.id}/posts/{post.id}/', + '/users/{user.id}/posts/{post.id}/junk', + ] as const)('parses path template with nested vars: %s', pathTemplate => { + const ast = parseURLPathTemplate(pathTemplate); + if (!ast) { + throw new Error('Generic parse failure for ' + pathTemplate); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(Object.keys(vars).sort()).toEqual([ + 'post.id', + 'user.id', + ]); + }); + + it.each([ + '/users/{user.id}?param={param}', + '/users/{user.id}/?param={param}', + '/users/{user.id}/junk?param={param}', + '/users/{user.id}/{param}?', + ] as const)('parses templates with query parameters: %s', pathTemplate => { + const ast = parseURLPathTemplate(pathTemplate); + if (!ast) { + throw new Error('Generic parse failure for ' + pathTemplate); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(Object.keys(vars).sort()).toEqual([ + 'param', + 'user.id', + ]); + }); + + it.each([ + '/location/{latitude},{longitude}?filter={filter}', + '/location/{latitude},{longitude}/?filter={filter}', + '/location/{latitude},{longitude}/junk?filter={filter}', + '/location/lat:{latitude},lon:{longitude}?filter={filter}', + '/location/lat:{latitude},lon:{longitude}/?filter={filter!}', + '/location/lat:{latitude},lon:{longitude}/junk?filter={filter!}', + '/?lat={latitude}&lon={longitude}&filter={filter}', + '/?location={latitude},{longitude}&filter={filter}', + '/?filter={filter}&location={latitude!}-{longitude!}', + ] as const)('should parse a template with multi-var segments: %s', pathTemplate => { + const ast = parseURLPathTemplate(pathTemplate); + if (!ast) { + throw new Error('Generic parse failure for ' + pathTemplate); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(Object.keys(vars).sort()).toEqual([ + 'filter', + 'latitude', + 'longitude', + ]); + }); + + it.each([ + '/users?ids={uid,...}&filter={filter}', + '/users_batch/{uid,...}?filter={filter}', + ] as const)('can parse batch endpoints: %s', pathTemplate => { + const ast = parseURLPathTemplate(pathTemplate); + if (!ast) { + throw new Error('Generic parse failure for ' + pathTemplate); + } + expect(ast.errors).toEqual([]); + const vars = getURLPathTemplateVars(ast); + expect(vars).toEqual({ + uid: { + batchSep: ',', + }, + filter: {}, + }); + }); +}); diff --git a/internals-js/src/specs/sourceSpec.ts b/internals-js/src/specs/sourceSpec.ts index b43f83731..cffc5b2cb 100644 --- a/internals-js/src/specs/sourceSpec.ts +++ b/internals-js/src/specs/sourceSpec.ts @@ -1,4 +1,5 @@ import { DirectiveLocation, GraphQLError, Kind } from 'graphql'; +import { Grammars, IToken } from 'ebnf'; import { FeatureDefinition, FeatureDefinitions, FeatureUrl, FeatureVersion, LinkDirectiveArgs } from "./index"; import { Schema, @@ -329,13 +330,18 @@ export class SourceSpecDefinition extends FeatureDefinition { )); } else { const urlPathTemplate = (GET || POST)!; - try { + const ast = parseURLPathTemplate(urlPathTemplate); + if (ast) { + ast.errors.forEach(error => { + errors.push(ERRORS.SOURCE_TYPE_HTTP_PATH_INVALID.err( + `${sourceType} http.GET or http.POST must be valid URL path template (error: ${error.message})` + )); + }); // TODO Validate URL path template uses only available @key fields // of the type. - parseURLPathTemplate(urlPathTemplate); - } catch (e) { + } else { errors.push(ERRORS.SOURCE_TYPE_HTTP_PATH_INVALID.err( - `${sourceType} http.GET or http.POST must be valid URL path template (error: ${e.message})` + `${sourceType} http.GET or http.POST must be valid URL path template` )); } } @@ -350,12 +356,18 @@ export class SourceSpecDefinition extends FeatureDefinition { )); } - try { - parseJSONSelection(body); - // TODO Validate body selection matches the available fields. - } catch (e) { + const ast = parseJSONSelection(body); + if (ast) { + ast.errors.forEach(error => { + errors.push(ERRORS.SOURCE_TYPE_HTTP_BODY_INVALID.err( + `${sourceType} http.body not valid JSONSelection (error: ${error.message})`, + { nodes: application.sourceAST }, + )); + }); + // TODO Validate body selection matches the available @key fields. + } else { errors.push(ERRORS.SOURCE_TYPE_HTTP_BODY_INVALID.err( - `${sourceType} http.body not valid JSONSelection (error: ${e.message})`, + `${sourceType} http.body not valid JSONSelection`, { nodes: application.sourceAST }, )); } @@ -372,12 +384,18 @@ export class SourceSpecDefinition extends FeatureDefinition { { nodes: application.sourceAST }, )); } - try { - parseJSONSelection(selection); + const sel = parseJSONSelection(selection); + if (sel) { + sel.errors.forEach(error => { + errors.push(ERRORS.SOURCE_TYPE_SELECTION_INVALID.err( + `${sourceType} selection not valid JSONSelection (error: ${error.message}): ${selection}`, + { nodes: application.sourceAST }, + )); + }); // TODO Validate selection is valid JSONSelection for type. - } catch (e) { + } else { errors.push(ERRORS.SOURCE_TYPE_SELECTION_INVALID.err( - `${sourceType} selection not valid JSONSelection (error: ${e.message})`, + `${sourceType} selection not valid JSONSelection: ${selection}`, { nodes: application.sourceAST }, )); } @@ -421,13 +439,18 @@ export class SourceSpecDefinition extends FeatureDefinition { )); } else if (usedMethods.length === 1) { const urlPathTemplate = usedMethods[0]!; - try { - // TODO Validate URL path template uses only available fields of - // the type and/or argument names of the field. - parseURLPathTemplate(urlPathTemplate); - } catch (e) { + const ast = parseURLPathTemplate(urlPathTemplate); + if (ast) { + ast.errors.forEach(error => { + errors.push(ERRORS.SOURCE_FIELD_HTTP_PATH_INVALID.err( + `${sourceField} http.{GET,POST,PUT,PATCH,DELETE} must be valid URL path template (error: ${error.message})` + )); + }); + // TODO Validate URL path template uses only available fields of the + // type and/or argument names of the field. + } else { errors.push(ERRORS.SOURCE_FIELD_HTTP_PATH_INVALID.err( - `${sourceField} http.{GET,POST,PUT,PATCH,DELETE} must be valid URL path template (error: ${e.message})` + `${sourceField} http.{GET,POST,PUT,PATCH,DELETE} must be valid URL path template`, )); } } @@ -447,13 +470,19 @@ export class SourceSpecDefinition extends FeatureDefinition { )); } - try { - parseJSONSelection(body); + const ast = parseJSONSelection(body); + if (ast) { + ast.errors.forEach(error => { + errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( + `${sourceField} http.body not valid JSONSelection (error: ${error.message}): ${body}`, + { nodes: application.sourceAST }, + )); + }); // TODO Validate body string matches the available fields of the // parent type and/or argument names of the field. - } catch (e) { + } else { errors.push(ERRORS.SOURCE_FIELD_HTTP_BODY_INVALID.err( - `${sourceField} http.body not valid JSONSelection (error: ${e.message})`, + `${sourceField} http.body not valid JSONSelection: ${body}`, { nodes: application.sourceAST }, )); } @@ -461,13 +490,19 @@ export class SourceSpecDefinition extends FeatureDefinition { } if (selection) { - try { - parseJSONSelection(selection); - // TODO Validate selection string matches the available fields of - // the parent type and/or argument names of the field. - } catch (e) { + const ast = parseJSONSelection(selection); + if (ast) { + ast.errors.forEach(error => { + errors.push(ERRORS.SOURCE_FIELD_SELECTION_INVALID.err( + `${sourceField} selection not valid JSONSelection (error: ${error.message}): ${selection}`, + { nodes: application.sourceAST }, + )); + }); + // TODO Validate selection string maps to declared fields of the + // result type of the field. + } else { errors.push(ERRORS.SOURCE_FIELD_SELECTION_INVALID.err( - `${sourceField} selection not valid JSONSelection (error: ${e.message})`, + `${sourceField} selection not valid JSONSelection: ${selection}`, { nodes: application.sourceAST }, )); } @@ -553,12 +588,194 @@ function validateHTTPHeaders( } } -function parseJSONSelection(_selection: string): any { - // TODO + +type ebnfASTNode = Pick +type Shape = string | { [key: string]: Shape } + +let selectionParser: Grammars.W3C.Parser | undefined; +export function parseJSONSelection(selection: string): ebnfASTNode | null { + selectionParser = selectionParser || new Grammars.W3C.Parser(` + Selection ::= NamedSelection* StarSelection? S? | PathSelection + NamedSelection ::= NamedFieldSelection | NamedQuotedSelection | NamedPathSelection | NamedGroupSelection + NamedFieldSelection ::= Alias? S? Identifier S? SubSelection? + NamedQuotedSelection ::= Alias StringLiteral S? SubSelection? + NamedPathSelection ::= Alias PathSelection + NamedGroupSelection ::= Alias SubSelection + PathSelection ::= S? ("." Property)+ S? SubSelection? + SubSelection ::= S? "{" S? NamedSelection* StarSelection? S? "}" S? + StarSelection ::= Alias? S? "*" S? SubSelection? + Alias ::= S? Identifier S? ":" S? + Property ::= Identifier | Integer | StringLiteral + Identifier ::= [a-zA-Z_][0-9a-zA-Z_]* + Integer ::= "0" | [1-9][0-9]* + StringLiteral ::= SQStrLit | DQStrLit + SQStrLit ::= "'" ("\\'" | [^'])* "'" + DQStrLit ::= '"' ('\\"' | [^"])* '"' + S ::= (Spaces | Comment)+ + Spaces ::= [ \t\r\n]+ + Comment ::= "#" [^\n]* + `); + return selectionParser.getAST(selection, 'Selection'); +} + +function findChildByType(node: ebnfASTNode | undefined, type: string): ebnfASTNode | undefined { + return node?.children.find(child => child.type === type); } -function parseURLPathTemplate(_template: string): any { - // TODO +export function getSelectionOutputShape(node: ebnfASTNode): Shape { + switch (node.type) { + case 'Selection': { + const pathSelection = findChildByType(node, 'PathSelection'); + if (pathSelection) { + return getSelectionOutputShape(pathSelection); + } + // Reuse the logic for SubSelection to handle the top-level sequence of + // NamedSelection and StarSelection nodes, which are equivalent to the + // contents of a SubSelection (minus the curly braces). + return getSelectionOutputShape({ + type: 'SubSelection', + children: node.children, + text: node.text, + errors: node.errors, + }); + } + case 'NamedSelection': { + const shape: { [key: string]: Shape } = Object.create(null); + // Typically node.children.length will be 1 here, but the for loop covers + // all conceivable cases. + for (const namedChild of node.children) { + Object.assign(shape, getSelectionOutputShape(namedChild)); + } + return shape; + } + case 'NamedFieldSelection': + case 'NamedQuotedSelection': + case 'NamedPathSelection': + case 'NamedGroupSelection': { + const outputName = ( + findChildByType(findChildByType(node, 'Alias'), 'Identifier') || + findChildByType(node, 'Identifier') || + findChildByType(node, 'StringLiteral') + )?.text; + + // PathSelection nests its optional SubSelection one level deeper than the + // other Named{Field,Quoted,Group}Selection types. + const subSelection = findChildByType( + findChildByType(node, 'PathSelection') || node, + 'SubSelection' + ); + + const shape: { [key: string]: Shape } = Object.create(null); + if (!outputName) { + // No output name, no contribution to the output shape. + } else if (subSelection) { + shape[outputName] = getSelectionOutputShape(subSelection); + } else { + shape[outputName] = 'JSON'; + } + return shape; + } + case 'PathSelection': { + const subSelection = findChildByType(node, 'SubSelection'); + return subSelection ? getSelectionOutputShape(subSelection) : 'JSON'; + } + case 'SubSelection': { + const shape: { [key: string]: Shape } = Object.create(null); + for (const child of node.children) { + if (child.type === 'NamedSelection') { + Object.assign(shape, getSelectionOutputShape(child)); + } else if (child.type === 'StarSelection') { + const starShape = getSelectionOutputShape(child); + if (typeof starShape === 'object') { + Object.assign(shape, starShape); + } + } + } + return shape; + } + case 'StarSelection': { + const shape: { [key: string]: Shape } = Object.create(null); + const outputName = findChildByType( + findChildByType(node, 'Alias'), + 'Identifier', + )?.text; + // From the GraphQL perspective, a star selection can only be typed as + // opaque JSON, though that JSON subtree may be given an alias. + if (outputName) { + shape[outputName] = 'JSON'; + } else { + return 'JSON'; + } + return shape; + } + // The rest of these cases are involved in the cases above, indirectly, but + // should not be reached during recursion. + // case 'Alias': break; + // case 'Property': break; + // case 'Identifier': break; + // case 'StringLiteral': break; + // case 'SQStrLit': break; + // case 'DQStrLit': break; + // case 'S': break; + // case 'Spaces': break; + // case 'Comment': break; + default: + throw new Error(`Unexpected JSONSelection AST node type: ${node.type}`); + } +} + +let urlParser: Grammars.W3C.Parser | undefined; +export function parseURLPathTemplate(template: string): ebnfASTNode | null { + urlParser = urlParser || new Grammars.W3C.Parser(` + URLPathTemplate ::= "/" PathParamList? QueryParamList? + PathParamList ::= VarSeparatedText ("/" VarSeparatedText)* "/"? + QueryParamList ::= "?" (QueryParam ("&" QueryParam)*)? + QueryParam ::= URLSafeText "=" VarSeparatedText | URLSafeText "="? + VarSeparatedText ::= OneOrMoreVars | URLSafeText + OneOrMoreVars ::= URLSafeText? "{" Var "}" (URLSafeText "{" Var "}")* URLSafeText? + Var ::= IdentifierPath Required? Batch? + IdentifierPath ::= Identifier ("." Identifier)* + Required ::= "!" + Batch ::= BatchSeparator "..." + BatchSeparator ::= "," | ";" | "|" | "+" | " " + URLSafeText ::= [^{}/?&=]+ + Identifier ::= [a-zA-Z_$][0-9a-zA-Z_$]* + `); + return urlParser.getAST(template, 'URLPathTemplate'); +} + +export function getURLPathTemplateVars(ast: ebnfASTNode) { + const vars: { + [varPath: string]: { + required?: boolean; + batchSep?: string; + }; + } = Object.create(null); + + function walk(node: ebnfASTNode) { + if (node.type === 'Var') { + let varPath: string | undefined; + const info: (typeof vars)[string] = {}; + node.children.forEach(child => { + if (child.type === 'IdentifierPath') { + varPath = child.text; + } else if (child.type === 'Required') { + info.required = true; + } else if (child.type === 'Batch') { + info.batchSep = child.children[0].text; + } + }); + if (varPath) { + vars[varPath] = info; + } + } else { + node.children.forEach(walk); + } + } + + walk(ast); + + return vars; } const HTTP_PROTOCOL = "http"; diff --git a/package-lock.json b/package-lock.json index 5ba7a25ec..c0b200504 100644 --- a/package-lock.json +++ b/package-lock.json @@ -130,6 +130,7 @@ "dependencies": { "@types/uuid": "^9.0.0", "chalk": "^4.1.0", + "ebnf": "^1.9.1", "js-levenshtein": "^1.1.6", "uuid": "^9.0.0" }, @@ -7011,6 +7012,14 @@ "node": ">=0.10" } }, + "node_modules/ebnf": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ebnf/-/ebnf-1.9.1.tgz", + "integrity": "sha512-uW2UKSsuty9ANJ3YByIQE4ANkD8nqUPO7r6Fwcc1ADKPe9FRdcPpMl3VEput4JSvKBJ4J86npIC2MLP0pYkCuw==", + "bin": { + "ebnf": "dist/bin.js" + } + }, "node_modules/ee-first": { "version": "1.1.1", "dev": true,