graphql-buddy 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/api.d.ts +42 -8
- package/dist/api.js +9 -5
- package/dist/chunk-ZIUWDMA7.js +190 -0
- package/dist/chunk-ZIUWDMA7.js.map +1 -0
- package/dist/cli.js +21 -6
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +3 -0
- package/src/api/commands/bundle.test.ts +4 -4
- package/src/api/commands/bundle.ts +1 -1
- package/src/api/commands/validate.test.ts +125 -24
- package/src/api/commands/validate.ts +36 -12
- package/src/api/shared/GraphqlFiles.ts +61 -0
- package/src/api/shared/index.ts +3 -0
- package/src/api/shared/loadDocuments.ts +48 -0
- package/src/api/shared/loadSchema.ts +11 -57
- package/src/api/shared/validateDocuments.ts +45 -0
- package/src/cli/commands/validate.ts +22 -5
- package/src/cli/index.ts +2 -0
- package/test_fixtures/documents/aliased_import/documents/AllFoo.graphql +7 -0
- package/test_fixtures/documents/aliased_import/documents/fragments/FooFields.graphql +4 -0
- package/test_fixtures/documents/aliased_import/schema/schema.graphql +20 -0
- package/test_fixtures/documents/anonymous_operation/documents/Anonymous.graphql +11 -0
- package/test_fixtures/documents/anonymous_operation/schema/schema.graphql +20 -0
- package/test_fixtures/documents/broken_import/documents/AllFoo.graphql +7 -0
- package/test_fixtures/documents/broken_import/schema/schema.graphql +20 -0
- package/test_fixtures/documents/duplicate_names/documents/First.graphql +5 -0
- package/test_fixtures/documents/duplicate_names/documents/Second.graphql +5 -0
- package/test_fixtures/documents/duplicate_names/schema/schema.graphql +20 -0
- package/test_fixtures/documents/fragment_library/documents/FooFields.graphql +4 -0
- package/test_fixtures/documents/fragment_library/schema/schema.graphql +20 -0
- package/test_fixtures/documents/imported_fragment/documents/AllFoo.graphql +12 -0
- package/test_fixtures/documents/imported_fragment/documents/FooFields.graphql +4 -0
- package/test_fixtures/documents/imported_fragment/schema/schema.graphql +20 -0
- package/test_fixtures/documents/invalid_fragment/documents/FooFields.graphql +4 -0
- package/test_fixtures/documents/invalid_fragment/schema/schema.graphql +20 -0
- package/test_fixtures/documents/missing_argument/documents/MissingArgument.graphql +5 -0
- package/test_fixtures/documents/missing_argument/schema/schema.graphql +20 -0
- package/test_fixtures/documents/mixed_sdl/documents/MixedSdl.graphql +9 -0
- package/test_fixtures/documents/mixed_sdl/schema/schema.graphql +20 -0
- package/test_fixtures/documents/nested_imports/documents/AllFoo.graphql +7 -0
- package/test_fixtures/documents/nested_imports/documents/BarFields.graphql +5 -0
- package/test_fixtures/documents/nested_imports/documents/FooFields.graphql +8 -0
- package/test_fixtures/documents/nested_imports/schema/schema.graphql +20 -0
- package/test_fixtures/documents/unknown_field/documents/BadFoo.graphql +6 -0
- package/test_fixtures/documents/unknown_field/schema/schema.graphql +20 -0
- package/test_fixtures/documents/unused_variable/documents/UnusedVariable.graphql +5 -0
- package/test_fixtures/documents/unused_variable/schema/schema.graphql +20 -0
- package/dist/chunk-Z7B4AQ2U.js +0 -124
- package/dist/chunk-Z7B4AQ2U.js.map +0 -1
- /package/test_fixtures/{missing_scalar → schema/missing_scalar}/schema.graphql +0 -0
- /package/test_fixtures/{multiple_files → schema/multiple_files}/Bar.graphql +0 -0
- /package/test_fixtures/{multiple_files → schema/multiple_files}/Baz.graphql +0 -0
- /package/test_fixtures/{multiple_files → schema/multiple_files}/Foo.graphql +0 -0
- /package/test_fixtures/{multiple_files → schema/multiple_files}/Mutation.graphql +0 -0
- /package/test_fixtures/{multiple_files → schema/multiple_files}/Query.graphql +0 -0
- /package/test_fixtures/{single_file → schema/single_file}/schema.graphql +0 -0
- /package/test_fixtures/{single_file_with_extra_deps → schema/single_file_with_extra_deps}/schema.graphql +0 -0
|
@@ -4,32 +4,133 @@ import { describe, expect, it } from 'vitest';
|
|
|
4
4
|
|
|
5
5
|
import * as validate from './validate';
|
|
6
6
|
|
|
7
|
+
const schemaFixture = (name: string) => ({
|
|
8
|
+
files: [`**/*.graphql`],
|
|
9
|
+
base: nodePath.resolve(__dirname, `../../../test_fixtures/schema/${name}`),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Each documents fixture is self-contained: `<case>/schema` holds the schema
|
|
14
|
+
* the documents are validated against and `<case>/documents` holds the
|
|
15
|
+
* operations and fragments under test.
|
|
16
|
+
*/
|
|
17
|
+
const documentsFixture = (
|
|
18
|
+
name: string,
|
|
19
|
+
options: { pathAliases?: Map<string, string> } = {},
|
|
20
|
+
) => {
|
|
21
|
+
const base = nodePath.resolve(
|
|
22
|
+
__dirname,
|
|
23
|
+
`../../../test_fixtures/documents/${name}`,
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
schema: {
|
|
27
|
+
files: [`**/*.graphql`],
|
|
28
|
+
base: nodePath.join(base, `schema`),
|
|
29
|
+
},
|
|
30
|
+
operations: {
|
|
31
|
+
files: [`*.graphql`],
|
|
32
|
+
base: nodePath.join(base, `documents`),
|
|
33
|
+
pathAliases: options.pathAliases,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
7
38
|
describe(`validate`, () => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
schema:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
)
|
|
39
|
+
describe(`schema`, () => {
|
|
40
|
+
it(`creates a validation stamp when validation passes.`, async () => {
|
|
41
|
+
await expect(
|
|
42
|
+
validate.validate({ schema: schemaFixture(`multiple_files`) }),
|
|
43
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it(`throws for invalid schema.`, async () => {
|
|
47
|
+
await expect(
|
|
48
|
+
validate.validate({ schema: schemaFixture(`missing_scalar`) }),
|
|
49
|
+
).rejects.toThrowError(`Unknown type "DateTime"`);
|
|
50
|
+
});
|
|
20
51
|
});
|
|
21
52
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
53
|
+
describe(`documents`, () => {
|
|
54
|
+
it(`validates operations against the schema, resolving imported fragments.`, async () => {
|
|
55
|
+
await expect(
|
|
56
|
+
validate.validate(documentsFixture(`imported_fragment`)),
|
|
57
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it(`resolves nested #import chains.`, async () => {
|
|
61
|
+
await expect(
|
|
62
|
+
validate.validate(documentsFixture(`nested_imports`)),
|
|
63
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it(`resolves #import paths through path aliases.`, async () => {
|
|
67
|
+
await expect(
|
|
68
|
+
validate.validate(
|
|
69
|
+
documentsFixture(`aliased_import`, {
|
|
70
|
+
pathAliases: new Map([[`@fragments/*`, `fragments/*`]]),
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it(`allows fragment-only files that nothing imports.`, async () => {
|
|
77
|
+
await expect(
|
|
78
|
+
validate.validate(documentsFixture(`fragment_library`)),
|
|
79
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it(`still validates field selections inside fragment-only files.`, async () => {
|
|
83
|
+
await expect(
|
|
84
|
+
validate.validate(documentsFixture(`invalid_fragment`)),
|
|
85
|
+
).rejects.toThrowError(`Cannot query field "thisFieldDoesNotExist"`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it(`throws for operations that reference unknown fields.`, async () => {
|
|
89
|
+
await expect(
|
|
90
|
+
validate.validate(documentsFixture(`unknown_field`)),
|
|
91
|
+
).rejects.toThrowError(`Cannot query field "thisFieldDoesNotExist"`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it(`throws for operations files that contain type-system definitions.`, async () => {
|
|
95
|
+
await expect(
|
|
96
|
+
validate.validate(documentsFixture(`mixed_sdl`)),
|
|
97
|
+
).rejects.toThrowError(`The "Bogus" definition is not executable.`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it(`throws for #import of a file that does not exist.`, async () => {
|
|
101
|
+
await expect(
|
|
102
|
+
validate.validate(documentsFixture(`broken_import`)),
|
|
103
|
+
).rejects.toThrowError(`Cannot find module './DoesNotExist.graphql'`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it(`throws for unused variables.`, async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
validate.validate(documentsFixture(`unused_variable`)),
|
|
109
|
+
).rejects.toThrowError(`Variable "$id" is never used`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it(`throws for missing required arguments.`, async () => {
|
|
113
|
+
await expect(
|
|
114
|
+
validate.validate(documentsFixture(`missing_argument`)),
|
|
115
|
+
).rejects.toThrowError(
|
|
116
|
+
`Field "foo" argument "id" of type "ID!" is required`,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it(`throws for anonymous operations that share a document with named ones.`, async () => {
|
|
121
|
+
await expect(
|
|
122
|
+
validate.validate(documentsFixture(`anonymous_operation`)),
|
|
123
|
+
).rejects.toThrowError(
|
|
124
|
+
`This anonymous operation must be the only defined operation.`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Documents are validated one file at a time, so an operation name reused
|
|
129
|
+
// across files is not detected. This pins that (documented) behavior.
|
|
130
|
+
it(`allows duplicate operation names across separate files.`, async () => {
|
|
131
|
+
await expect(
|
|
132
|
+
validate.validate(documentsFixture(`duplicate_names`)),
|
|
133
|
+
).resolves.toStrictEqual(expect.any(String));
|
|
134
|
+
});
|
|
34
135
|
});
|
|
35
136
|
});
|
|
@@ -3,12 +3,18 @@ import * as stringifyObject from 'stringify-object';
|
|
|
3
3
|
import * as shared from '../shared';
|
|
4
4
|
|
|
5
5
|
export type ValidateOptions = {
|
|
6
|
-
schema: shared.
|
|
6
|
+
schema: shared.GraphqlFiles;
|
|
7
|
+
|
|
8
|
+
// Optional GraphQL operation documents (queries, mutations, subscriptions,
|
|
9
|
+
// and fragments) to validate against the loaded schema. When omitted, only
|
|
10
|
+
// the schema itself is validated.
|
|
11
|
+
operations?: shared.GraphqlFiles | null;
|
|
7
12
|
};
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Validates that the provided GraphQL schema contain valid syntax and have no
|
|
11
|
-
* unknown symbols.
|
|
16
|
+
* unknown symbols. When operations are provided, additionally validates that
|
|
17
|
+
* every operation is valid against that schema.
|
|
12
18
|
*
|
|
13
19
|
* @param options - Validate options.
|
|
14
20
|
*
|
|
@@ -22,28 +28,46 @@ export const validate = async (options: ValidateOptions): Promise<string> => {
|
|
|
22
28
|
// accurate data in our validation stamp. For example, normalization expands
|
|
23
29
|
// globs and resolves absolute paths. These are critical steps to ensure the
|
|
24
30
|
// validation stamp describes without ambiguity which schema were loaded.
|
|
25
|
-
const normalizedSchemaOptions = await shared.
|
|
31
|
+
const normalizedSchemaOptions = await shared.normalizeGraphqlFiles(
|
|
26
32
|
options.schema,
|
|
27
33
|
);
|
|
28
34
|
|
|
29
|
-
await shared.loadSchema(normalizedSchemaOptions);
|
|
35
|
+
const schema = await shared.loadSchema(normalizedSchemaOptions);
|
|
36
|
+
|
|
37
|
+
if (options.operations == null) {
|
|
38
|
+
return createValidationStamp({ schema: normalizedSchemaOptions });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const normalizedOperationsOptions = await shared.normalizeGraphqlFiles(
|
|
42
|
+
options.operations,
|
|
43
|
+
);
|
|
44
|
+
shared.validateDocuments({
|
|
45
|
+
schema,
|
|
46
|
+
documents: await shared.loadDocuments(normalizedOperationsOptions),
|
|
47
|
+
});
|
|
30
48
|
|
|
31
49
|
return createValidationStamp({
|
|
32
|
-
...options,
|
|
33
50
|
schema: normalizedSchemaOptions,
|
|
51
|
+
operations: normalizedOperationsOptions,
|
|
34
52
|
});
|
|
35
53
|
};
|
|
36
54
|
|
|
37
|
-
const createValidationStamp = (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
): string =>
|
|
55
|
+
const createValidationStamp = (options: {
|
|
56
|
+
schema: shared.NormalizedGraphqlFiles;
|
|
57
|
+
operations?: shared.NormalizedGraphqlFiles;
|
|
58
|
+
}): string =>
|
|
42
59
|
stringifyObject.default(
|
|
43
|
-
{
|
|
60
|
+
{
|
|
61
|
+
options: {
|
|
62
|
+
schema: options.schema,
|
|
63
|
+
operations: options.operations,
|
|
64
|
+
},
|
|
65
|
+
outcome: { valid: true },
|
|
66
|
+
},
|
|
44
67
|
{
|
|
45
68
|
indent: ` `,
|
|
46
69
|
filter: (container, property) =>
|
|
47
|
-
!(container === options.schema && property === `normalized`)
|
|
70
|
+
!(container === options.schema && property === `normalized`) &&
|
|
71
|
+
!(container === options.operations && property === `normalized`),
|
|
48
72
|
},
|
|
49
73
|
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
|
|
3
|
+
import * as glob from 'glob';
|
|
4
|
+
|
|
5
|
+
export type GraphqlFiles = UnsafeGraphqlFiles | NormalizedGraphqlFiles;
|
|
6
|
+
|
|
7
|
+
export type UnsafeGraphqlFiles = {
|
|
8
|
+
normalized?: false | null;
|
|
9
|
+
|
|
10
|
+
// Relative paths will be resolved using `base`.
|
|
11
|
+
//
|
|
12
|
+
// Also accepts glob patterns.
|
|
13
|
+
files: Array<string>;
|
|
14
|
+
|
|
15
|
+
// Directory from which `files` are resolved.
|
|
16
|
+
//
|
|
17
|
+
// Defaults to `process.cwd()`.
|
|
18
|
+
base?: string | null;
|
|
19
|
+
|
|
20
|
+
// See @graphql-tools/import#PathAliases
|
|
21
|
+
//
|
|
22
|
+
// Note that relative paths will be resolved relative to `base` to maintain
|
|
23
|
+
// parity with `files`.
|
|
24
|
+
pathAliases?: Map<string, string> | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type NormalizedGraphqlFiles = {
|
|
28
|
+
normalized: true;
|
|
29
|
+
|
|
30
|
+
// Absolute paths.
|
|
31
|
+
files: Array<string>;
|
|
32
|
+
|
|
33
|
+
// See @graphql-tools/import#PathAliases
|
|
34
|
+
pathAliases: Map<string, string>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const normalizeGraphqlFiles = async (
|
|
38
|
+
graphqlFiles: GraphqlFiles,
|
|
39
|
+
): Promise<NormalizedGraphqlFiles> => {
|
|
40
|
+
if (graphqlFiles.normalized) {
|
|
41
|
+
return graphqlFiles;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const base = graphqlFiles.base ?? process.cwd();
|
|
45
|
+
const files = await glob.glob(
|
|
46
|
+
graphqlFiles.files.map((file) => nodePath.resolve(base, file)),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const pathAliases = new Map<string, string>();
|
|
50
|
+
if (graphqlFiles.pathAliases != null) {
|
|
51
|
+
for (const [alias, source] of graphqlFiles.pathAliases.entries()) {
|
|
52
|
+
pathAliases.set(alias, nodePath.resolve(base, source));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
normalized: true,
|
|
58
|
+
files,
|
|
59
|
+
pathAliases,
|
|
60
|
+
};
|
|
61
|
+
};
|
package/src/api/shared/index.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as nodeFs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import * as graphqlFileLoader from '@graphql-tools/graphql-file-loader';
|
|
4
|
+
import * as graphqlLoad from '@graphql-tools/load';
|
|
5
|
+
|
|
6
|
+
import type * as graphqlUtils from '@graphql-tools/utils';
|
|
7
|
+
|
|
8
|
+
import * as graphqlFilesModule from './GraphqlFiles';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loads, validates, and parses graphql documents.
|
|
12
|
+
*
|
|
13
|
+
* @param graphqlFiles - Operation or fragment files to process.
|
|
14
|
+
*
|
|
15
|
+
* @returns The parsed GraphQL documents (or throws an error if parsing fails).
|
|
16
|
+
*/
|
|
17
|
+
export const loadDocuments = async (
|
|
18
|
+
graphqlFiles: graphqlFilesModule.GraphqlFiles,
|
|
19
|
+
): Promise<Array<graphqlUtils.Source>> => {
|
|
20
|
+
const { files, pathAliases } =
|
|
21
|
+
await graphqlFilesModule.normalizeGraphqlFiles(graphqlFiles);
|
|
22
|
+
|
|
23
|
+
// @graphql-tools/graphql-file-loader silently skips input files which are not
|
|
24
|
+
// found. I suspect this is so that it can delegate missing inputs to other
|
|
25
|
+
// loaders. For our purposes, this is undesirable because we want to inform
|
|
26
|
+
// clients if expected inputs are missing. For that reason, we manually
|
|
27
|
+
// validate that the files exist first.
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
try {
|
|
30
|
+
await nodeFs.access(file, nodeFs.constants.F_OK);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`File does not exist: ${file}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return await graphqlLoad.loadDocuments(files, {
|
|
37
|
+
loaders: [new graphqlFileLoader.GraphQLFileLoader()],
|
|
38
|
+
// By default @graphql-tools/load strips type definitions out of executable
|
|
39
|
+
// documents, so SDL that lands in an operations file (or a schema file
|
|
40
|
+
// caught by an operations glob) would silently vanish. Keeping every
|
|
41
|
+
// definition lets `ExecutableDefinitionsRule` reject it during validation
|
|
42
|
+
// instead.
|
|
43
|
+
filterKinds: [],
|
|
44
|
+
pathAliases: {
|
|
45
|
+
mappings: Object.fromEntries(pathAliases.entries()),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
};
|
|
@@ -1,38 +1,24 @@
|
|
|
1
1
|
import * as nodeFs from 'node:fs/promises';
|
|
2
|
-
import * as nodePath from 'node:path';
|
|
3
2
|
|
|
4
3
|
import * as graphqlFileLoader from '@graphql-tools/graphql-file-loader';
|
|
5
4
|
import * as graphqlLoad from '@graphql-tools/load';
|
|
6
|
-
import * as glob from 'glob';
|
|
7
5
|
|
|
8
6
|
import type * as graphql from 'graphql';
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
normalized?: false | null;
|
|
12
|
-
|
|
13
|
-
// Relative paths will be resolved using `base`.
|
|
14
|
-
//
|
|
15
|
-
// Also accepts glob patterns.
|
|
16
|
-
files: Array<string>;
|
|
17
|
-
|
|
18
|
-
// Directory from which `files` are resolved.
|
|
19
|
-
//
|
|
20
|
-
// Defaults to `process.cwd()`.
|
|
21
|
-
base?: string | null;
|
|
22
|
-
|
|
23
|
-
// See @graphql-tools/import#PathAliases
|
|
24
|
-
//
|
|
25
|
-
// Note that relative paths will be resolved relative to `base` to maintain
|
|
26
|
-
// parity with `files`.
|
|
27
|
-
pathAliases?: Map<string, string> | null;
|
|
28
|
-
};
|
|
8
|
+
import * as graphqlFilesModule from './GraphqlFiles';
|
|
29
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Loads, validates, and parses graphql schema.
|
|
12
|
+
*
|
|
13
|
+
* @param graphqlFiles - Schema files to process.
|
|
14
|
+
*
|
|
15
|
+
* @returns The parsed GraphQL schema (or throws an error if parsing fails).
|
|
16
|
+
*/
|
|
30
17
|
export const loadSchema = async (
|
|
31
|
-
|
|
18
|
+
graphqlFiles: graphqlFilesModule.GraphqlFiles,
|
|
32
19
|
): Promise<graphql.GraphQLSchema> => {
|
|
33
|
-
const { files, pathAliases } =
|
|
34
|
-
|
|
35
|
-
: await normalizeLoadSchemaOptions(options);
|
|
20
|
+
const { files, pathAliases } =
|
|
21
|
+
await graphqlFilesModule.normalizeGraphqlFiles(graphqlFiles);
|
|
36
22
|
|
|
37
23
|
// @graphql-tools/graphql-file-loader silently skips input files which are not
|
|
38
24
|
// found. I suspect this is so that it can delegate missing inputs to other
|
|
@@ -54,35 +40,3 @@ export const loadSchema = async (
|
|
|
54
40
|
},
|
|
55
41
|
});
|
|
56
42
|
};
|
|
57
|
-
|
|
58
|
-
export type NormalizedLoadSchemaOptions = {
|
|
59
|
-
normalized: true;
|
|
60
|
-
|
|
61
|
-
// Absolute paths.
|
|
62
|
-
files: Array<string>;
|
|
63
|
-
|
|
64
|
-
// See @graphql-tools/import#PathAliases
|
|
65
|
-
pathAliases: Map<string, string>;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export const normalizeLoadSchemaOptions = async (
|
|
69
|
-
options: LoadSchemaOptions,
|
|
70
|
-
): Promise<NormalizedLoadSchemaOptions> => {
|
|
71
|
-
const base = options.base ?? process.cwd();
|
|
72
|
-
const files = await glob.glob(
|
|
73
|
-
options.files.map((file) => nodePath.resolve(base, file)),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
const pathAliases = new Map<string, string>();
|
|
77
|
-
if (options.pathAliases != null) {
|
|
78
|
-
for (const [alias, source] of options.pathAliases.entries()) {
|
|
79
|
-
pathAliases.set(alias, nodePath.resolve(base, source));
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
normalized: true,
|
|
85
|
-
files,
|
|
86
|
-
pathAliases,
|
|
87
|
-
};
|
|
88
|
-
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as graphql from 'graphql';
|
|
2
|
+
|
|
3
|
+
import type * as graphqlUtils from '@graphql-tools/utils';
|
|
4
|
+
|
|
5
|
+
export type ValidateDocumentsOptions = {
|
|
6
|
+
schema: graphql.GraphQLSchema;
|
|
7
|
+
documents: Array<graphqlUtils.Source>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validates GraphQL documents against their target schema.
|
|
12
|
+
*
|
|
13
|
+
* @param options - Validation options (schema and documents).
|
|
14
|
+
*
|
|
15
|
+
* @throws GraphQLError instances when validation fails.
|
|
16
|
+
*/
|
|
17
|
+
export const validateDocuments = (options: ValidateDocumentsOptions): void => {
|
|
18
|
+
const { schema, documents } = options;
|
|
19
|
+
|
|
20
|
+
for (const source of documents) {
|
|
21
|
+
if (source.document == null) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fragment-only documents are fragment "libraries" that operations pull in
|
|
26
|
+
// via `#import`. Validated in isolation they would trip
|
|
27
|
+
// `NoUnusedFragmentsRule`, so we drop that single rule for them. Every
|
|
28
|
+
// other rule, most importantly that each referenced field, argument, and
|
|
29
|
+
// type actually exists, are still enforced.
|
|
30
|
+
const definesOperation = source.document.definitions.some(
|
|
31
|
+
(definition) => definition.kind === graphql.Kind.OPERATION_DEFINITION,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const errors = graphql.validate(
|
|
35
|
+
schema,
|
|
36
|
+
source.document,
|
|
37
|
+
graphql.specifiedRules.filter(
|
|
38
|
+
(rule) => definesOperation || rule !== graphql.NoUnusedFragmentsRule,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
if (errors.length > 0) {
|
|
42
|
+
throw errors[0];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -9,7 +9,9 @@ import * as shared from '../shared';
|
|
|
9
9
|
export const createValidateCommand = () =>
|
|
10
10
|
new commander.Command()
|
|
11
11
|
.name(`validate`)
|
|
12
|
-
.description(
|
|
12
|
+
.description(
|
|
13
|
+
`Checks that all provided GraphQL files form a valid schema, and optionally that operations are valid against it.`,
|
|
14
|
+
)
|
|
13
15
|
.argument(`<schema...>`, `Input GraphQL schema files to validate.`)
|
|
14
16
|
.option(
|
|
15
17
|
`-b, --base <directory>`,
|
|
@@ -19,6 +21,10 @@ export const createValidateCommand = () =>
|
|
|
19
21
|
`-a, --alias <pattern...>`,
|
|
20
22
|
`Path alias options for @graphql-tools/load.`,
|
|
21
23
|
)
|
|
24
|
+
.option(
|
|
25
|
+
`-o, --operations <operations...>`,
|
|
26
|
+
`GraphQL operation documents to validate against the schema.`,
|
|
27
|
+
)
|
|
22
28
|
.option(
|
|
23
29
|
`--stamp <file>`,
|
|
24
30
|
`Creates a validation stamp to confirm valid GraphQL schema.`,
|
|
@@ -35,18 +41,29 @@ const validate = async (options: {
|
|
|
35
41
|
schema: Array<string>;
|
|
36
42
|
base?: string;
|
|
37
43
|
alias?: Array<string>;
|
|
44
|
+
operations?: Array<string>;
|
|
38
45
|
stamp?: string;
|
|
39
46
|
silent: boolean;
|
|
40
47
|
}): Promise<void> => {
|
|
41
|
-
const { schema, base, alias, stamp, silent } = options;
|
|
48
|
+
const { schema, base, alias, operations, stamp, silent } = options;
|
|
42
49
|
|
|
43
|
-
const
|
|
50
|
+
const pathAliases = shared.parsePathAliases(alias ?? []);
|
|
51
|
+
|
|
52
|
+
const validateOptions: api.ValidateOptions = {
|
|
44
53
|
schema: {
|
|
45
54
|
files: schema,
|
|
46
55
|
base,
|
|
47
|
-
pathAliases
|
|
56
|
+
pathAliases,
|
|
48
57
|
},
|
|
49
|
-
}
|
|
58
|
+
};
|
|
59
|
+
if (operations != null) {
|
|
60
|
+
validateOptions.operations = {
|
|
61
|
+
files: operations,
|
|
62
|
+
base,
|
|
63
|
+
pathAliases,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const validationStamp = await api.validate(validateOptions);
|
|
50
67
|
|
|
51
68
|
if (stamp != null) {
|
|
52
69
|
const destination = nodePath.resolve(base ?? process.cwd(), stamp);
|
package/src/cli/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import * as commands from './commands';
|
|
|
7
7
|
|
|
8
8
|
const main = () => {
|
|
9
9
|
process.setUncaughtExceptionCaptureCallback((error) => {
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
|
|
10
12
|
// When thrown natively, GraphQLError prints like any other error with
|
|
11
13
|
// minimal context, however, when logged with `.toString()` it prints
|
|
12
14
|
// helpful debugging information critical for debugging syntax errors (such
|