diff --git a/.changeset/graphql-sdl-linting.md b/.changeset/graphql-sdl-linting.md new file mode 100644 index 0000000000..35473ee460 --- /dev/null +++ b/.changeset/graphql-sdl-linting.md @@ -0,0 +1,6 @@ +--- +'@redocly/openapi-core': minor +'@redocly/cli': minor +--- + +Added initial support for linting GraphQL SDL schema files (`.graphql` / `.gql`). diff --git a/package-lock.json b/package-lock.json index d6c79887aa..464832e686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4436,6 +4436,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphql": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.1.tgz", + "integrity": "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -9057,6 +9066,7 @@ "ajv": "npm:@redocly/ajv@8.18.1", "ajv-formats": "^3.0.1", "colorette": "^1.2.0", + "graphql": "^16.9.0", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "picomatch": "^4.0.4", diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index 2611e20238..141c1ecc73 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -27,6 +27,7 @@ export const configFixture: Config = { arazzo1: {}, overlay1: {}, openrpc1: {}, + graphql: {}, }, preprocessors: { oas2: {}, @@ -38,6 +39,7 @@ export const configFixture: Config = { arazzo1: {}, overlay1: {}, openrpc1: {}, + graphql: {}, }, plugins: [], doNotResolveExamples: false, @@ -51,6 +53,7 @@ export const configFixture: Config = { arazzo1: {}, overlay1: {}, openrpc1: {}, + graphql: {}, }, resolveIgnore: vi.fn(), addProblemToIgnore: vi.fn(), diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts index 1a036aa0fa..d79e0d33b0 100644 --- a/packages/cli/src/__tests__/utils.test.ts +++ b/packages/cli/src/__tests__/utils.test.ts @@ -614,6 +614,7 @@ describe('checkIfRulesetExist', () => { arazzo1: {}, overlay1: {}, openrpc1: {}, + graphql: {}, }; expect(() => checkIfRulesetExist(rules)).toThrowError( '⚠️ No rules were configured. Learn how to configure rules: https://redocly.com/docs/cli/rules/' @@ -631,6 +632,7 @@ describe('checkIfRulesetExist', () => { arazzo1: {}, overlay1: {}, openrpc1: {}, + graphql: {}, }; checkIfRulesetExist(rules); }); diff --git a/packages/cli/src/utils/miscellaneous.ts b/packages/cli/src/utils/miscellaneous.ts index 1061bd02fc..8148bdf43b 100644 --- a/packages/cli/src/utils/miscellaneous.ts +++ b/packages/cli/src/utils/miscellaneous.ts @@ -546,6 +546,8 @@ export function checkIfRulesetExist(rules: typeof Config.prototype.rules) { ...rules.async3, ...rules.arazzo1, ...rules.overlay1, + ...rules.openrpc1, + ...rules.graphql, }; if (isEmptyObject(ruleset)) { diff --git a/packages/core/package.json b/packages/core/package.json index a8a58994c3..af783fe53b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,6 +57,7 @@ "ajv": "npm:@redocly/ajv@8.18.1", "ajv-formats": "^3.0.1", "colorette": "^1.2.0", + "graphql": "^16.9.0", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", "picomatch": "^4.0.4", diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index 7e026eed64..b363d8f219 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -241,6 +241,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "properties": {}, }, "graphql": "rootRedoclyConfigSchema.apis_additionalProperties.graphql", + "graphqlRules": "Rules", "metadata": { "type": "object", }, @@ -301,6 +302,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "name": "Extends", "properties": {}, }, + "graphqlRules": "Rules", "oas2Decorators": "Decorators", "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", @@ -384,6 +386,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "feedback": "rootRedoclyConfigSchema.feedback", "footer": "rootRedoclyConfigSchema.footer", "graphql": "rootRedoclyConfigSchema.graphql", + "graphqlRules": "Rules", "i18n": "rootRedoclyConfigSchema.i18n", "ignore": { "items": { @@ -942,6 +945,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "name": "Extends", "properties": {}, }, + "graphqlRules": "Rules", "name": { "type": "string", }, diff --git a/packages/core/src/bundle/bundle-visitor.ts b/packages/core/src/bundle/bundle-visitor.ts index f255295d49..d090a971f4 100644 --- a/packages/core/src/bundle/bundle-visitor.ts +++ b/packages/core/src/bundle/bundle-visitor.ts @@ -106,6 +106,9 @@ export function mapTypeToComponent(typeName: string, version: SpecMajorVersion) default: return null; } + case 'graphql': + // GraphQL SDL is never bundled/$ref-resolved. + return null; } } diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index ed14440bc6..6af5dc2fb6 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -61,6 +61,10 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "warn", + "type-pascal-case": "warn", + }, "oas2Decorators": {}, "oas2Preprocessors": {}, "oas2Rules": { @@ -448,6 +452,10 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "warn", + "type-pascal-case": "warn", + }, "oas2Decorators": {}, "oas2Preprocessors": {}, "oas2Rules": { diff --git a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap index c744acb40c..ea74125243 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap @@ -10,6 +10,7 @@ Config { "arazzo1": {}, "async2": {}, "async3": {}, + "graphql": {}, "oas2": {}, "oas3_0": {}, "oas3_1": {}, @@ -36,6 +37,7 @@ Config { "arazzo1": {}, "async2": {}, "async3": {}, + "graphql": {}, "oas2": {}, "oas3_0": {}, "oas3_1": {}, @@ -64,6 +66,10 @@ Config { "no-empty-servers": "error", "operation-summary": "error", }, + "graphql": { + "no-empty-servers": "error", + "operation-summary": "error", + }, "oas2": { "no-empty-servers": "error", "operation-summary": "error", diff --git a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap index cb8265aec4..fde15b1f99 100644 --- a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap @@ -10,6 +10,7 @@ Config { "arazzo1": {}, "async2": {}, "async3": {}, + "graphql": {}, "oas2": {}, "oas3_0": {}, "oas3_1": {}, @@ -25,6 +26,7 @@ Config { "arazzo1": {}, "async2": {}, "async3": {}, + "graphql": {}, "oas2": {}, "oas3_0": {}, "oas3_1": {}, @@ -51,6 +53,7 @@ Config { "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, + "graphqlRules": {}, "oas2Decorators": {}, "oas2Preprocessors": {}, "oas2Rules": {}, @@ -158,6 +161,22 @@ Config { "no-empty-servers": "error", "operation-summary": "error", }, + "graphql": { + "assertions": [ + { + "assertionId": "rule/test", + "assertions": { + "defined": true, + }, + "subject": { + "property": "x-test", + "type": "Operation", + }, + }, + ], + "no-empty-servers": "error", + "operation-summary": "error", + }, "oas2": { "assertions": [ { diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts index 4e4f185ea1..5a169c03c4 100644 --- a/packages/core/src/config/__tests__/config.test.ts +++ b/packages/core/src/config/__tests__/config.test.ts @@ -34,6 +34,7 @@ describe('Config.forAlias', () => { "arazzo1": {}, "async2": {}, "async3": {}, + "graphql": {}, "oas2": {}, "oas3_0": {}, "oas3_1": {}, @@ -75,6 +76,7 @@ describe('Config.forAlias', () => { "arazzo1": {}, "async2": {}, "async3": {}, + "graphql": {}, "oas2": {}, "oas3_0": {}, "oas3_1": {}, @@ -99,6 +101,7 @@ describe('Config.forAlias', () => { "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, + "graphqlRules": {}, "oas2Decorators": {}, "oas2Preprocessors": {}, "oas2Rules": {}, @@ -144,6 +147,10 @@ describe('Config.forAlias', () => { "no-empty-servers": "error", "operation-summary": "warn", }, + "graphql": { + "no-empty-servers": "error", + "operation-summary": "warn", + }, "oas2": { "no-empty-servers": "error", "operation-summary": "warn", diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index e88230d17e..3c20b9fddc 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -184,6 +184,10 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "off", + "type-pascal-case": "off", + }, "name": "Baseline", "oas2Decorators": {}, "oas2Preprocessors": {}, @@ -508,6 +512,10 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "warn", + "type-pascal-case": "warn", + }, "name": "Silver", "oas2Decorators": {}, "oas2Preprocessors": {}, @@ -837,6 +845,10 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "off", + "type-pascal-case": "off", + }, "name": "Gold", "oas2Decorators": {}, "oas2Preprocessors": {}, @@ -1250,6 +1262,10 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "off", + "type-pascal-case": "off", + }, "name": "Baseline", "oas2Decorators": {}, "oas2Preprocessors": {}, @@ -1574,6 +1590,10 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "warn", + "type-pascal-case": "warn", + }, "name": "Silver", "oas2Decorators": {}, "oas2Preprocessors": {}, @@ -1903,6 +1923,10 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "graphqlRules": { + "type-description": "off", + "type-pascal-case": "off", + }, "name": "Gold", "oas2Decorators": {}, "oas2Preprocessors": {}, diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts index fbdd42c095..67cf36eb79 100644 --- a/packages/core/src/config/all.ts +++ b/packages/core/src/config/all.ts @@ -312,6 +312,10 @@ const all: RawGovernanceConfig<'built-in'> = { 'spec-no-duplicated-method-params': 'error', 'spec-no-required-params-after-optional': 'error', }, + graphqlRules: { + 'type-description': 'error', + 'type-pascal-case': 'error', + }, }; export default all; diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index c8a975724b..6141c1df8d 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -17,6 +17,7 @@ import { rules as async3Rules, preprocessors as async3Preprocessors, } from '../rules/async3/index.js'; +import { rules as graphqlRules } from '../rules/graphql/index.js'; import { rules as oas2Rules, preprocessors as oas2Preprocessors } from '../rules/oas2/index.js'; import { rules as oas3Rules, preprocessors as oas3Preprocessors } from '../rules/oas3/index.js'; import { @@ -52,6 +53,7 @@ export const defaultPlugin: Plugin<'built-in'> = { arazzo1: arazzo1Rules, overlay1: overlay1Rules, openrpc1: openrpc1Rules, + graphql: graphqlRules, }, preprocessors: { oas3: oas3Preprocessors, diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts index 529b0faf9b..8fe4da69d1 100644 --- a/packages/core/src/config/config-resolvers.ts +++ b/packages/core/src/config/config-resolvers.ts @@ -339,10 +339,11 @@ export async function resolvePlugins( !pluginInstance.rules.async3 && !pluginInstance.rules.arazzo1 && !pluginInstance.rules.overlay1 && - !pluginInstance.rules.openrpc1 + !pluginInstance.rules.openrpc1 && + !pluginInstance.rules.graphql ) { throw new Error( - `Plugin rules must have \`oas3\`, \`oas2\`, \`async2\`, \`async3\`, \`arazzo\`, \`overlay1\`, or \`openrpc1\` rules "${p}.` + `Plugin rules must have \`oas3\`, \`oas2\`, \`async2\`, \`async3\`, \`arazzo\`, \`overlay1\`, \`openrpc1\`, or \`graphql\` rules "${p}.` ); } plugin.rules = {}; @@ -367,6 +368,9 @@ export async function resolvePlugins( if (pluginInstance.rules.openrpc1) { plugin.rules.openrpc1 = prefixRules(pluginInstance.rules.openrpc1, id); } + if (pluginInstance.rules.graphql) { + plugin.rules.graphql = prefixRules(pluginInstance.rules.graphql, id); + } } if (pluginInstance.preprocessors) { if ( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7ef6647bbe..dd53ad9a3d 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -11,6 +11,7 @@ import type { Arazzo1RuleSet, Overlay1RuleSet, OpenRpc1RuleSet, + GraphqlRuleSet, SpecVersion, SpecMajorVersion, } from '../oas-types.js'; @@ -87,6 +88,7 @@ export class Config { arazzo1: group({ ...resolvedConfig.rules, ...resolvedConfig.arazzo1Rules }), overlay1: group({ ...resolvedConfig.rules, ...resolvedConfig.overlay1Rules }), openrpc1: group({ ...resolvedConfig.rules, ...resolvedConfig.openrpc1Rules }), + graphql: group({ ...resolvedConfig.rules, ...resolvedConfig.graphqlRules }), }; this.preprocessors = { @@ -123,6 +125,7 @@ export class Config { ...resolvedConfig.preprocessors, ...resolvedConfig.openrpc1Preprocessors, }, + graphql: { ...resolvedConfig.preprocessors }, }; this.decorators = { @@ -141,6 +144,7 @@ export class Config { ...resolvedConfig.decorators, ...resolvedConfig.openrpc1Decorators, }, + graphql: { ...resolvedConfig.decorators }, }; this.ignore = opts.ignore ?? {}; @@ -394,6 +398,11 @@ export class Config { (p) => p.decorators?.openrpc1 && openrpc1Rules.push(p.decorators.openrpc1) ); return openrpc1Rules; + case 'graphql': + // eslint-disable-next-line no-case-declarations + const graphqlRules: GraphqlRuleSet[] = []; + this.plugins.forEach((p) => p.rules?.graphql && graphqlRules.push(p.rules.graphql)); + return graphqlRules; } } diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts index 4a00e4964e..16c6d5e450 100644 --- a/packages/core/src/config/minimal.ts +++ b/packages/core/src/config/minimal.ts @@ -284,6 +284,10 @@ const minimal: RawGovernanceConfig<'built-in'> = { overlay1Rules: { 'info-contact': 'off', }, + graphqlRules: { + 'type-description': 'off', + 'type-pascal-case': 'off', + }, openrpc1Rules: { 'info-contact': 'off', 'info-license': 'off', diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts index 250a6d5636..d76a981c07 100644 --- a/packages/core/src/config/recommended-strict.ts +++ b/packages/core/src/config/recommended-strict.ts @@ -291,6 +291,10 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'spec-no-duplicated-method-params': 'error', 'spec-no-required-params-after-optional': 'error', }, + graphqlRules: { + 'type-description': 'error', + 'type-pascal-case': 'error', + }, }; export default recommendedStrict; diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts index f3333ef029..6bba288a96 100644 --- a/packages/core/src/config/recommended.ts +++ b/packages/core/src/config/recommended.ts @@ -291,6 +291,10 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'spec-no-duplicated-method-params': 'error', 'spec-no-required-params-after-optional': 'error', }, + graphqlRules: { + 'type-description': 'warn', + 'type-pascal-case': 'warn', + }, }; export default recommended; diff --git a/packages/core/src/config/rules.ts b/packages/core/src/config/rules.ts index 7b32ba9f6c..5c265b958b 100644 --- a/packages/core/src/config/rules.ts +++ b/packages/core/src/config/rules.ts @@ -6,6 +6,7 @@ import type { Oas3RuleSet, Overlay1RuleSet, OpenRpc1RuleSet, + GraphqlRuleSet, SpecVersion, } from '../oas-types.js'; import { isDefined } from '../utils/is-defined.js'; @@ -27,6 +28,7 @@ export function initRules( | Arazzo1RuleSet | Overlay1RuleSet | OpenRpc1RuleSet + | GraphqlRuleSet )[], config: Config, type: 'rules' | 'preprocessors' | 'decorators', diff --git a/packages/core/src/config/spec.ts b/packages/core/src/config/spec.ts index bea4c5e16e..f83b61618a 100644 --- a/packages/core/src/config/spec.ts +++ b/packages/core/src/config/spec.ts @@ -291,6 +291,10 @@ const spec: RawGovernanceConfig<'built-in'> = { 'spec-no-duplicated-method-params': 'error', 'spec-no-required-params-after-optional': 'error', }, + graphqlRules: { + 'type-description': 'off', + 'type-pascal-case': 'off', + }, }; export default spec; diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index f23bc30826..d5c2822f95 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -19,6 +19,7 @@ import type { Overlay1RuleSet, OpenRpc1RuleSet, OpenRpc1DecoratorsSet, + GraphqlRuleSet, } from '../oas-types.js'; import type { Location } from '../ref-utils.js'; import type { NodeType } from '../types/index.js'; @@ -30,6 +31,7 @@ import type { BuiltInArazzo1RuleId, BuiltInOverlay1RuleId, BuiltInOpenRpc1RuleId, + BuiltInGraphqlRuleId, BuiltInCommonRuleId, BuiltInOas2DecoratorId, BuiltInOas3DecoratorId, @@ -93,6 +95,7 @@ export type RawGovernanceConfig = arazzo1Rules?: RuleMap; overlay1Rules?: RuleMap; openrpc1Rules?: RuleMap; + graphqlRules?: RuleMap; preprocessors?: Record; oas2Preprocessors?: Record< @@ -175,6 +178,7 @@ export type RulesConfig = { arazzo1?: Arazzo1RuleSet; overlay1?: Overlay1RuleSet; openrpc1?: OpenRpc1RuleSet; + graphql?: GraphqlRuleSet; }; export type CustomRulesConfig = RulesConfig; diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index 0e6ec7cd79..0181704763 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -6,6 +6,7 @@ import type { Arazzo1RuleSet, Overlay1RuleSet, OpenRpc1RuleSet, + GraphqlRuleSet, } from '../oas-types.js'; import { assignOnlyExistingConfig, assignConfig } from '../utils/assign-config.js'; import { isPlainObject } from '../utils/is-plain-object.js'; @@ -28,7 +29,8 @@ export function prefixRules< | Async2RuleSet | Arazzo1RuleSet | Overlay1RuleSet - | OpenRpc1RuleSet, + | OpenRpc1RuleSet + | GraphqlRuleSet, >(rules: T, prefix: string) { if (!prefix) return rules; @@ -52,6 +54,7 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { arazzo1Rules: {}, overlay1Rules: {}, openrpc1Rules: {}, + graphqlRules: {}, preprocessors: {}, oas2Preprocessors: {}, @@ -102,6 +105,8 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignOnlyExistingConfig(result.overlay1Rules, rulesConf.rules); assignConfig(result.openrpc1Rules, rulesConf.openrpc1Rules); assignOnlyExistingConfig(result.openrpc1Rules, rulesConf.rules); + assignConfig(result.graphqlRules, rulesConf.graphqlRules); + assignOnlyExistingConfig(result.graphqlRules, rulesConf.rules); assignConfig(result.preprocessors, rulesConf.preprocessors); assignConfig(result.oas2Preprocessors, rulesConf.oas2Preprocessors); diff --git a/packages/core/src/detect-spec.ts b/packages/core/src/detect-spec.ts index 682845dc29..16ae473310 100644 --- a/packages/core/src/detect-spec.ts +++ b/packages/core/src/detect-spec.ts @@ -12,6 +12,7 @@ export const specVersions = [ 'arazzo1', 'overlay1', 'openrpc1', + 'graphql', ] as const; export function getMajorSpecVersion(version: SpecVersion): SpecMajorVersion { @@ -27,6 +28,8 @@ export function getMajorSpecVersion(version: SpecVersion): SpecMajorVersion { return 'overlay1'; } else if (version === 'openrpc1') { return 'openrpc1'; + } else if (version === 'graphql') { + return 'graphql'; } else { return 'oas3'; } diff --git a/packages/core/src/graphql/extensions.ts b/packages/core/src/graphql/extensions.ts new file mode 100644 index 0000000000..c077d8f32f --- /dev/null +++ b/packages/core/src/graphql/extensions.ts @@ -0,0 +1,6 @@ +export const GRAPHQL_EXTENSIONS = ['.graphql', '.gql']; + +export function isGraphqlRef(ref: string): boolean { + const lowerCasedRef = ref.toLowerCase(); + return GRAPHQL_EXTENSIONS.some((ext) => lowerCasedRef.endsWith(ext)); +} diff --git a/packages/core/src/graphql/run.ts b/packages/core/src/graphql/run.ts new file mode 100644 index 0000000000..9ae4be3a89 --- /dev/null +++ b/packages/core/src/graphql/run.ts @@ -0,0 +1,103 @@ +import { getLocation, visit, visitInParallel, type ASTNode, type ASTVisitor } from 'graphql'; + +import type { Config } from '../config/index.js'; +import type { Source } from '../resolve.js'; +import type { LineColLocationObject, NormalizedProblem, ProblemSeverity } from '../walk.js'; +import type { + GraphqlProblem, + GraphqlUserContext, + GraphqlVisitFunction, + GraphqlVisitor, +} from './visitor.js'; + +export type InitializedGraphqlRule = { + ruleId: string; + severity: ProblemSeverity; + message?: string; + visitor: GraphqlVisitor; +}; + +type ContextOptions = { + ruleId: string; + severity: ProblemSeverity; + message?: string; + source: Source; + config?: Config; + problems: NormalizedProblem[]; +}; + +export function runGraphqlRules(opts: { + ast: ASTNode; + source: Source; + config?: Config; + rules: InitializedGraphqlRule[]; +}): NormalizedProblem[] { + const { ast, source, config, rules } = opts; + const problems: NormalizedProblem[] = []; + + const astVisitors = rules.map(({ ruleId, severity, message, visitor }) => + toAstVisitor(visitor, { ruleId, severity, message, source, config, problems }) + ); + + if (astVisitors.length > 0) { + visit(ast, visitInParallel(astVisitors)); + } + + return problems; +} + +function toAstVisitor(visitor: GraphqlVisitor, opts: ContextOptions): ASTVisitor { + const astVisitor: Record = {}; + for (const kind of Object.keys(visitor) as Array) { + const handler = visitor[kind]; + if (!handler) continue; + const enter = typeof handler === 'function' ? handler : handler.enter; + const leave = typeof handler === 'function' ? undefined : handler.leave; + astVisitor[kind] = { + ...(enter ? { enter: wrap(enter, opts) } : {}), + ...(leave ? { leave: wrap(leave, opts) } : {}), + }; + } + return astVisitor as ASTVisitor; +} + +function wrap(fn: GraphqlVisitFunction, opts: ContextOptions) { + return (node: ASTNode) => { + fn(node, makeContext(node, opts)); + }; +} + +function makeContext(currentNode: ASTNode | undefined, opts: ContextOptions): GraphqlUserContext { + const { ruleId, severity, message, source, config, problems } = opts; + return { + source, + config, + report(problem: GraphqlProblem) { + const node = problem.node ?? currentNode; + const loc = problem.loc ?? nodeToLoc(node); + const location: LineColLocationObject = { + source, + pointer: undefined, + start: loc?.start ?? { line: 1, col: 1 }, + end: loc?.end, + }; + problems.push({ + ruleId, + severity, + message: message ? message.replace('{{message}}', problem.message) : problem.message, + suggest: problem.suggest ?? [], + location: [location], + }); + }, + }; +} + +export function nodeToLoc(node?: ASTNode) { + if (!node?.loc) return undefined; + const start = getLocation(node.loc.source, node.loc.start); + const end = getLocation(node.loc.source, node.loc.end); + return { + start: { line: start.line, col: start.column }, + end: { line: end.line, col: end.column }, + }; +} diff --git a/packages/core/src/graphql/visitor.ts b/packages/core/src/graphql/visitor.ts new file mode 100644 index 0000000000..8664eed62f --- /dev/null +++ b/packages/core/src/graphql/visitor.ts @@ -0,0 +1,33 @@ +import type { ASTNode } from 'graphql'; + +import type { Config } from '../config/index.js'; +import type { Source } from '../resolve.js'; +import type { Loc } from '../walk.js'; + +export type GraphqlNodeKind = ASTNode['kind']; + +export type GraphqlProblem = { + message: string; + // Override the node whose location is reported (defaults to the visited node). + node?: ASTNode; + // Fully explicit location, takes precedence over `node`. + loc?: { start: Loc; end?: Loc }; + suggest?: string[]; +}; + +export type GraphqlUserContext = { + report(problem: GraphqlProblem): void; + source: Source; + config?: Config; +}; + +export type GraphqlVisitFunction = (node: any, ctx: GraphqlUserContext) => void; + +export type GraphqlVisitorNode = + | GraphqlVisitFunction + | { enter?: GraphqlVisitFunction; leave?: GraphqlVisitFunction }; + +// Mirrors the OAS visitor shape: keyed by node kind, `enter` shorthand supported. +export type GraphqlVisitor = Partial>; + +export type GraphqlRule = (options: Record) => GraphqlVisitor; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a50abee51..0caf4acb81 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,13 @@ export { type Totals, } from './format/format.js'; export { lint, lint as validate, lintDocument, lintFromString, lintConfig } from './lint.js'; +export { lintGraphqlDocument, isGraphqlDocument } from './lint-graphql.js'; +export type { + GraphqlRule, + GraphqlVisitor, + GraphqlUserContext, + GraphqlProblem, +} from './graphql/visitor.js'; export { lintEntityFile, lintEntityWithScorecardLevel, lintSchema } from './lint-entity.js'; export { bundle, bundleFromString, type BundleResult } from './bundle/bundle.js'; export { bundleDocument } from './bundle/bundle-document.js'; diff --git a/packages/core/src/lint-graphql.ts b/packages/core/src/lint-graphql.ts new file mode 100644 index 0000000000..5ceaebc0d4 --- /dev/null +++ b/packages/core/src/lint-graphql.ts @@ -0,0 +1,55 @@ +import { GraphQLError, parse, Source as GraphqlSource, type DocumentNode } from 'graphql'; + +import type { Config } from './config/index.js'; +import { initRules } from './config/rules.js'; +import { isGraphqlRef } from './graphql/extensions.js'; +import { runGraphqlRules, type InitializedGraphqlRule } from './graphql/run.js'; +import type { GraphqlRuleSet } from './oas-types.js'; +import type { Document, Source } from './resolve.js'; +import type { NormalizedProblem } from './walk.js'; + +export function isGraphqlDocument(document: Document): boolean { + return isGraphqlRef(document.source.absoluteRef); +} + +export function lintGraphqlDocument(opts: { + document: Document; + config: Config; +}): NormalizedProblem[] { + const { document, config } = opts; + const source = document.source; + + let ast: DocumentNode; + try { + ast = parse(new GraphqlSource(source.body, source.absoluteRef)); + } catch (e) { + if (e instanceof GraphQLError) { + // Syntax errors are always reported as errors and short-circuit the file. + return [syntaxErrorToProblem(e, source)]; + } + throw e; + } + + const ruleSets = (config.getRulesForSpecVersion('graphql') ?? []) as GraphqlRuleSet[]; + const rules = initRules(ruleSets, config, 'rules', 'graphql') as InitializedGraphqlRule[]; + + const problems = runGraphqlRules({ ast, source, config, rules }); + return problems.map((problem) => config.addProblemToIgnore(problem)); +} + +function syntaxErrorToProblem(error: GraphQLError, source: Source): NormalizedProblem { + const loc = error.locations?.[0]; + return { + ruleId: 'struct', + severity: 'error', + message: error.message, + suggest: [], + location: [ + { + source, + pointer: undefined, + start: loc ? { line: loc.line, col: loc.column } : { line: 1, col: 1 }, + }, + ], + }; +} diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts index 9e81fa6f2f..d1f1e15e61 100755 --- a/packages/core/src/lint.ts +++ b/packages/core/src/lint.ts @@ -3,6 +3,7 @@ import { rootRedoclyConfigSchema } from '@redocly/config'; import type { Config } from './config/index.js'; import { initRules } from './config/rules.js'; import { detectSpec, getMajorSpecVersion } from './detect-spec.js'; +import { isGraphqlDocument, lintGraphqlDocument } from './lint-graphql.js'; import { getTypes } from './oas-types.js'; import { BaseResolver, resolveDocument, makeDocumentFromString, type Document } from './resolve.js'; import { releaseAjvInstance } from './rules/ajv.js'; @@ -74,6 +75,12 @@ export async function lintDocument(opts: { releaseAjvInstance(); // FIXME: preprocessors can modify nodes which are then cached to ajv-instance by absolute path const { document, customTypes, externalRefResolver, config } = opts; + + // GraphQL SDL is not a JSON/YAML tree, so it runs through a separate engine. + if (isGraphqlDocument(document)) { + return lintGraphqlDocument({ document, config }); + } + const specVersion = detectSpec(document.parsed); const specMajorVersion = getMajorSpecVersion(specVersion); const rules = config.getRulesForSpecVersion(specMajorVersion); diff --git a/packages/core/src/oas-types.ts b/packages/core/src/oas-types.ts index 04c9bdb74e..5a54c8c974 100644 --- a/packages/core/src/oas-types.ts +++ b/packages/core/src/oas-types.ts @@ -1,3 +1,4 @@ +import type { GraphqlRule } from './graphql/visitor.js'; import { Arazzo1Types } from './types/arazzo.js'; import { AsyncApi2Types } from './types/asyncapi2.js'; import { AsyncApi3Types } from './types/asyncapi3.js'; @@ -16,6 +17,7 @@ import type { BuiltInOverlay1RuleId, BuiltInCommonRuleId, BuiltInOpenRpc1RuleId, + BuiltInGraphqlRuleId, BuiltInOas2DecoratorId, BuiltInOas3DecoratorId, } from './types/redocly-yaml.js'; @@ -46,6 +48,7 @@ export const specVersions = [ 'arazzo1', 'overlay1', 'openrpc1', + 'graphql', ] as const; export type SpecVersion = (typeof specVersions)[number]; @@ -56,7 +59,8 @@ export type SpecMajorVersion = | 'async3' | 'arazzo1' | 'overlay1' - | 'openrpc1'; + | 'openrpc1' + | 'graphql'; const typesMap = { oas2: Oas2Types, @@ -112,6 +116,14 @@ export type OpenRpc1RuleSet = RuleMap< T >; +// GraphQL has a separate engine: it reuses the common `struct` rule but not +// `no-unresolved-refs`/`assertions` (which walk the JSON tree). +export type GraphqlRuleSet = RuleMap< + BuiltInGraphqlRuleId | 'struct', + GraphqlRule, + T +>; + export type Oas3DecoratorsSet = Record< T extends 'built-in' ? BuiltInOas3DecoratorId : string, Oas3Decorator @@ -127,5 +139,7 @@ export type Overlay1DecoratorsSet = Record; export type OpenRpc1DecoratorsSet = Record; export function getTypes(spec: SpecVersion) { - return typesMap[spec]; + // GraphQL uses a separate engine and never resolves a JSON `NodeType` tree. + // FIXME: I don't like this. Maybe let's get rid of the graphql key in SpecVersion? do we need it? + return typesMap[spec as keyof typeof typesMap]; } diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts index a3903ee1c7..f32820d479 100644 --- a/packages/core/src/resolve.ts +++ b/packages/core/src/resolve.ts @@ -4,6 +4,7 @@ import type { YAMLNode, LoadOptions } from 'yaml-ast-parser'; import type { ResolveConfig } from './config/types.js'; import { YamlParseError } from './errors/yaml-parse-error.js'; +import { isGraphqlRef } from './graphql/extensions.js'; import { parseYaml } from './js-yaml/index.js'; import { isRef, @@ -79,6 +80,10 @@ export function makeDocumentFromString( absoluteRef: string ): Document { const source = new Source(absoluteRef, sourceString); + // GraphQL SDL is not YAML/JSON: keep the raw body for the GraphQL engine. + if (isGraphqlRef(absoluteRef)) { + return { source, parsed: sourceString as T }; + } try { return { source, @@ -130,6 +135,10 @@ export class BaseResolver { } parseDocument(source: Source, isRoot: boolean = false): Document { + // GraphQL SDL is not YAML/JSON: keep the raw body for the GraphQL engine. + if (isGraphqlRef(source.absoluteRef)) { + return { source, parsed: source.body }; + } const ext = source.absoluteRef.substr(source.absoluteRef.lastIndexOf('.')); if ( !['.json', '.json', '.yml', '.yaml'].includes(ext) && diff --git a/packages/core/src/rules/graphql/__tests__/graphql.test.ts b/packages/core/src/rules/graphql/__tests__/graphql.test.ts new file mode 100644 index 0000000000..11e745ca32 --- /dev/null +++ b/packages/core/src/rules/graphql/__tests__/graphql.test.ts @@ -0,0 +1,125 @@ +import { outdent } from 'outdent'; + +import { createConfig, type RuleConfig } from '../../../config/index.js'; +import { lintFromString } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +const allRules: Record = { + struct: 'error', + 'type-description': 'error', + 'type-pascal-case': 'error', +}; + +async function lintGraphql(source: string, rules: Record = allRules) { + return lintFromString({ + source, + absoluteRef: 'schema.graphql', + config: await createConfig({ rules }), + externalRefResolver: new BaseResolver(), + }); +} + +describe('GraphQL SDL linting', () => { + it('reports no problems for a valid schema', async () => { + const results = await lintGraphql(outdent` + """Root query type.""" + type Query { + """Fetch a user by id.""" + user(id: ID!): User + } + + """A registered user.""" + type User { + id: ID! + name: String! + email: String + } + `); + + expect(results).toHaveLength(0); + }); + + it('reports a syntax error via the struct rule and short-circuits', async () => { + const results = await lintGraphql(outdent` + type Query { + user(id: ID!): User + `); + + expect(results).toHaveLength(1); + expect(results[0].ruleId).toBe('struct'); + expect(results[0].severity).toBe('error'); + expect(results[0].message).toMatch(/Syntax Error/i); + expect(results[0].location[0].pointer).toBeUndefined(); + expect(results[0].location[0]).toHaveProperty('start'); + }); + + it('reports schema-validity errors via the struct rule', async () => { + const results = await lintGraphql(outdent` + type Query { + user: User + } + `); + + const structProblems = results.filter((problem) => problem.ruleId === 'struct'); + expect(structProblems.length).toBeGreaterThan(0); + expect(structProblems.every((problem) => problem.severity === 'error')).toBe(true); + }); + + it('reports types without a description via the type-description rule', async () => { + const results = await lintGraphql( + outdent` + type Query { + ping: String + } + `, + { struct: 'off', 'type-description': 'error' } + ); + + const descriptionProblems = results.filter((problem) => problem.ruleId === 'type-description'); + expect(descriptionProblems.length).toBeGreaterThan(0); + expect(descriptionProblems[0].message).toMatch(/description/i); + }); + + it('reports non-PascalCase type names via the type-pascal-case rule', async () => { + const results = await lintGraphql( + outdent` + """A type with a bad name.""" + type query { + ping: String + } + `, + { struct: 'off', 'type-pascal-case': 'error' } + ); + + const pascalProblems = results.filter((problem) => problem.ruleId === 'type-pascal-case'); + expect(pascalProblems.length).toBeGreaterThan(0); + expect(pascalProblems[0].message).toMatch(/PascalCase/i); + }); + + it('respects severity from config (warn)', async () => { + const results = await lintGraphql( + outdent` + type query { + ping: String + } + `, + { struct: 'off', 'type-pascal-case': 'warn', 'type-description': 'off' } + ); + + expect(results.length).toBeGreaterThan(0); + expect(results.every((problem) => problem.severity === 'warn')).toBe(true); + }); + + it('does not run rules turned off in config', async () => { + const results = await lintGraphql( + outdent` + type query { + ping: String + } + `, + { struct: 'off', 'type-pascal-case': 'off', 'type-description': 'off' } + ); + + expect(results).toHaveLength(0); + }); +}); diff --git a/packages/core/src/rules/graphql/index.ts b/packages/core/src/rules/graphql/index.ts new file mode 100644 index 0000000000..b5ab9fcf16 --- /dev/null +++ b/packages/core/src/rules/graphql/index.ts @@ -0,0 +1,12 @@ +import type { GraphqlRuleSet } from '../../oas-types.js'; +import { Struct } from './struct.js'; +import { TypeDescription } from './type-description.js'; +import { TypePascalCase } from './type-pascal-case.js'; + +export const rules: GraphqlRuleSet<'built-in'> = { + struct: Struct, + 'type-description': TypeDescription, + 'type-pascal-case': TypePascalCase, +}; + +export const preprocessors = {}; diff --git a/packages/core/src/rules/graphql/struct.ts b/packages/core/src/rules/graphql/struct.ts new file mode 100644 index 0000000000..e299847d04 --- /dev/null +++ b/packages/core/src/rules/graphql/struct.ts @@ -0,0 +1,45 @@ +import { buildASTSchema, GraphQLError, validateSchema, type DocumentNode } from 'graphql'; + +import type { GraphqlRule, GraphqlUserContext } from '../../graphql/visitor.js'; + +// Structural validity for GraphQL SDL: builds the schema from the AST and runs +// the schema-validity checks (undefined types, duplicate fields, missing root, ...). +export const Struct: GraphqlRule = () => { + return { + Document: { + enter: (node: DocumentNode, ctx: GraphqlUserContext) => { + let schema; + try { + schema = buildASTSchema(node, { assumeValidSDL: false }); + } catch (e) { + reportThrown(e, ctx); + return; + } + + for (const error of validateSchema(schema)) { + reportGraphqlError(error, ctx); + } + }, + }, + }; +}; + +function reportThrown(e: unknown, ctx: GraphqlUserContext) { + if (e instanceof GraphQLError) { + reportGraphqlError(e, ctx); + } else if (e instanceof Error) { + // `buildASTSchema` aggregates SDL validation errors into a single Error. + ctx.report({ message: e.message }); + } else { + throw e; + } +} + +function reportGraphqlError(error: GraphQLError, ctx: GraphqlUserContext) { + const loc = error.locations?.[0]; + ctx.report({ + message: error.message, + node: error.nodes?.[0], + loc: loc ? { start: { line: loc.line, col: loc.column } } : undefined, + }); +} diff --git a/packages/core/src/rules/graphql/type-description.ts b/packages/core/src/rules/graphql/type-description.ts new file mode 100644 index 0000000000..d6c46f12ec --- /dev/null +++ b/packages/core/src/rules/graphql/type-description.ts @@ -0,0 +1,28 @@ +import type { NameNode } from 'graphql'; + +import type { GraphqlRule, GraphqlUserContext } from '../../graphql/visitor.js'; + +type TypeDefinitionNode = { + name: NameNode; + description?: { value: string }; +}; + +export const TypeDescription: GraphqlRule = () => { + const checkDescription = (node: TypeDefinitionNode, ctx: GraphqlUserContext) => { + if (!node.description || node.description.value.trim() === '') { + ctx.report({ + message: `Type \`${node.name.value}\` should have a non-empty description.`, + node: node.name, + }); + } + }; + + return { + ObjectTypeDefinition: checkDescription, + InterfaceTypeDefinition: checkDescription, + EnumTypeDefinition: checkDescription, + InputObjectTypeDefinition: checkDescription, + UnionTypeDefinition: checkDescription, + ScalarTypeDefinition: checkDescription, + }; +}; diff --git a/packages/core/src/rules/graphql/type-pascal-case.ts b/packages/core/src/rules/graphql/type-pascal-case.ts new file mode 100644 index 0000000000..7a6d5cba2d --- /dev/null +++ b/packages/core/src/rules/graphql/type-pascal-case.ts @@ -0,0 +1,30 @@ +import type { NameNode } from 'graphql'; + +import type { GraphqlRule, GraphqlUserContext } from '../../graphql/visitor.js'; + +type NamedTypeNode = { + name: NameNode; +}; + +const PASCAL_CASE = /^[A-Z][a-zA-Z0-9]*$/; + +export const TypePascalCase: GraphqlRule = () => { + const checkName = (node: NamedTypeNode, ctx: GraphqlUserContext) => { + const name = node.name.value; + if (!PASCAL_CASE.test(name)) { + ctx.report({ + message: `Type \`${name}\` should be in PascalCase.`, + node: node.name, + }); + } + }; + + return { + ObjectTypeDefinition: checkName, + InterfaceTypeDefinition: checkName, + EnumTypeDefinition: checkName, + InputObjectTypeDefinition: checkName, + UnionTypeDefinition: checkName, + ScalarTypeDefinition: checkName, + }; +}; diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index fefcfcf823..bee35914e7 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -192,6 +192,9 @@ const builtInOpenRpc1Rules = [ ] as const; export type BuiltInOpenRpc1RuleId = (typeof builtInOpenRpc1Rules)[number]; +const builtInGraphqlRules = ['type-description', 'type-pascal-case'] as const; +export type BuiltInGraphqlRuleId = (typeof builtInGraphqlRules)[number]; + const builtInCommonRules = ['struct', 'no-unresolved-refs'] as const; export type BuiltInCommonRuleId = (typeof builtInCommonRules)[number]; @@ -203,6 +206,7 @@ const builtInRules = [ ...builtInArazzo1Rules, ...builtInOverlay1Rules, ...builtInOpenRpc1Rules, + ...builtInGraphqlRules, ...builtInCommonRules, ] as const; type BuiltInRuleId = (typeof builtInRules)[number]; @@ -267,6 +271,7 @@ const configGovernanceProperties: Record< arazzo1Rules: 'Rules', overlay1Rules: 'Rules', openrpc1Rules: 'Rules', + graphqlRules: 'Rules', preprocessors: 'Preprocessors', oas2Preprocessors: 'Preprocessors', oas3_0Preprocessors: 'Preprocessors', @@ -709,7 +714,11 @@ const ConfigurableRule: NodeType = { export function createConfigTypes(extraSchemas: JSONSchema, config?: Config) { const nodeNames = specVersions.flatMap((version) => { - const types = config ? config.extendTypes(getTypes(version), version) : getTypes(version); + // FIXME: this is not needed, i believe. no types for graphql - no need to change here. because we're creating types not only for graphql, but for all specs. + // GraphQL uses a separate engine and has no JSON `NodeType` tree. + const baseTypes = getTypes(version); + if (!baseTypes) return []; + const types = config ? config.extendTypes(baseTypes, version) : baseTypes; return Object.keys(types); }); // Create types based on external schemas diff --git a/tests/e2e/lint/graphql-invalid/redocly.yaml b/tests/e2e/lint/graphql-invalid/redocly.yaml new file mode 100644 index 0000000000..2465aead77 --- /dev/null +++ b/tests/e2e/lint/graphql-invalid/redocly.yaml @@ -0,0 +1,10 @@ +apis: + main: + root: ./schema.graphql + +extends: + - recommended + +rules: + type-description: error + type-pascal-case: error diff --git a/tests/e2e/lint/graphql-invalid/schema.graphql b/tests/e2e/lint/graphql-invalid/schema.graphql new file mode 100644 index 0000000000..c7b2fa3fe2 --- /dev/null +++ b/tests/e2e/lint/graphql-invalid/schema.graphql @@ -0,0 +1,10 @@ +""" +Root query type. +""" +type Query { + product: product +} + +type product { + id: ID! +} diff --git a/tests/e2e/lint/graphql-invalid/snapshot.txt b/tests/e2e/lint/graphql-invalid/snapshot.txt new file mode 100644 index 0000000000..3a4845bfbd --- /dev/null +++ b/tests/e2e/lint/graphql-invalid/snapshot.txt @@ -0,0 +1,35 @@ +[1] schema.graphql:8:6 + +Type `product` should have a non-empty description. + + 6 | } + 7 | + 8 | type product { + | ^^^^^^^ + 9 | id: ID! +10 | } + +Error was generated by the type-description rule. + + +[2] schema.graphql:8:6 + +Type `product` should be in PascalCase. + + 6 | } + 7 | + 8 | type product { + | ^^^^^^^ + 9 | id: ID! +10 | } + +Error was generated by the type-pascal-case rule. + + + +validating schema.graphql using lint rules for api 'main'... +schema.graphql: validated in ms + +❌ Validation failed with 2 errors. +run `redocly lint --generate-ignore-file` to add all problems to the ignore file. + diff --git a/tests/e2e/lint/graphql-valid/redocly.yaml b/tests/e2e/lint/graphql-valid/redocly.yaml new file mode 100644 index 0000000000..edf1e2b166 --- /dev/null +++ b/tests/e2e/lint/graphql-valid/redocly.yaml @@ -0,0 +1,6 @@ +apis: + main: + root: ./schema.graphql + +extends: + - recommended diff --git a/tests/e2e/lint/graphql-valid/schema.graphql b/tests/e2e/lint/graphql-valid/schema.graphql new file mode 100644 index 0000000000..4fae3001d7 --- /dev/null +++ b/tests/e2e/lint/graphql-valid/schema.graphql @@ -0,0 +1,18 @@ +""" +Root query type. +""" +type Query { + """ + Fetch a user by id. + """ + user(id: ID!): User +} + +""" +A registered user. +""" +type User { + id: ID! + name: String! + email: String +} diff --git a/tests/e2e/lint/graphql-valid/snapshot.txt b/tests/e2e/lint/graphql-valid/snapshot.txt new file mode 100644 index 0000000000..efcf8545df --- /dev/null +++ b/tests/e2e/lint/graphql-valid/snapshot.txt @@ -0,0 +1,6 @@ + +validating schema.graphql using lint rules for api 'main'... +schema.graphql: validated in ms + +Woohoo! Your API description is valid. 🎉 +