Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/cspell-dict.txt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Didnt
diretive
doesn
Dont
ebnf
effecient
elimiate
elts
Expand Down
3 changes: 2 additions & 1 deletion internals-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
304 changes: 303 additions & 1 deletion internals-js/src/specs/__tests__/sourceSpec.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
Comment on lines +79 to +85
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output shape of a JSONSelection string is a tree of nested JSON dictionaries mirroring the structure of a GraphQL query, including scalar properties as leaf nodes. The ultimate goal is to validate this structure against object fields to ensure all fields in the schema are handled by some JSONSelection mapping.

These 'JSON' leaf values could have been any placeholder value, but I wanted to emphasize we can't assume much about the types of leaf fields within JSONSelection output shapes, except that they must be some JSON value (including possibly null for nullable fields).

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: {},
});
});
});
Loading