From cfd1b001127364fb327652b128e59032c671c68e Mon Sep 17 00:00:00 2001 From: Roman Hotsiy Date: Wed, 10 Apr 2024 17:39:59 +0800 Subject: [PATCH] chore: wip config validation, extend types before --- .changeset/tender-vans-cover.md | 6 + .../openapi.yaml | 7 + .../plugins/type-extention.js | 27 ++++ .../redocly.yaml | 13 ++ .../snapshot.js | 7 + .../openapi.yaml | 7 + .../plugins/type-extention.js | 27 ++++ .../redocly.yaml | 13 ++ .../snapshot.js | 22 +++ __tests__/commands.test.ts | 24 +++ .../snapshot.js | 6 +- .../deprecated-apiDefinitions/snapshot.js | 4 +- __tests__/lint/deprecated-lint/snapshot.js | 2 +- .../lint/deprecated-styleguide/snapshot.js | 2 +- packages/cli/src/commands/lint.ts | 9 +- packages/core/src/__tests__/lint.test.ts | 34 ++-- .../core/src/config/__tests__/load.test.ts | 147 ++++++++++-------- packages/core/src/config/load.ts | 72 ++++++--- packages/core/src/config/utils.ts | 4 + packages/core/src/lint.ts | 19 +-- packages/core/src/types/redocly-yaml.ts | 65 ++++---- 21 files changed, 368 insertions(+), 149 deletions(-) create mode 100644 .changeset/tender-vans-cover.md create mode 100644 __tests__/check-config/config-type-extensions-in-assertions/openapi.yaml create mode 100644 __tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js create mode 100644 __tests__/check-config/config-type-extensions-in-assertions/redocly.yaml create mode 100644 __tests__/check-config/config-type-extensions-in-assertions/snapshot.js create mode 100644 __tests__/check-config/wrong-config-type-extensions-in-assertions/openapi.yaml create mode 100644 __tests__/check-config/wrong-config-type-extensions-in-assertions/plugins/type-extention.js create mode 100644 __tests__/check-config/wrong-config-type-extensions-in-assertions/redocly.yaml create mode 100644 __tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js diff --git a/.changeset/tender-vans-cover.md b/.changeset/tender-vans-cover.md new file mode 100644 index 000000000..0babfee03 --- /dev/null +++ b/.changeset/tender-vans-cover.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": minor +"@redocly/cli": minor +--- + +Changed resolution process to include extendedTypes and plugins before linting. diff --git a/__tests__/check-config/config-type-extensions-in-assertions/openapi.yaml b/__tests__/check-config/config-type-extensions-in-assertions/openapi.yaml new file mode 100644 index 000000000..b38dfc95b --- /dev/null +++ b/__tests__/check-config/config-type-extensions-in-assertions/openapi.yaml @@ -0,0 +1,7 @@ +openapi: 3.1.0 +info: + title: Food Empire API + version: 0.5.1 + x-metadata: + lifecycle: production + owner-team: Engineering/Integrations diff --git a/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js b/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js new file mode 100644 index 000000000..7dca3c6a9 --- /dev/null +++ b/__tests__/check-config/config-type-extensions-in-assertions/plugins/type-extention.js @@ -0,0 +1,27 @@ +const XMetaData = { + properties: { + lifecycle: { type: 'string', enum: ['development', 'staging', 'production'] }, + 'owner-team': { type: 'string' }, + }, + required: ['lifecycle'], +}; + +module.exports = { + id: 'type-extension', + typeExtension: { + oas3(types) { + newTypes = { + ...types, + XMetaData: XMetaData, + Info: { + ...types.Info, + properties: { + ...types.Info.properties, + 'x-metadata': 'XMetaData', + }, + }, + }; + return newTypes; + }, + }, +}; diff --git a/__tests__/check-config/config-type-extensions-in-assertions/redocly.yaml b/__tests__/check-config/config-type-extensions-in-assertions/redocly.yaml new file mode 100644 index 000000000..d2bd1ac47 --- /dev/null +++ b/__tests__/check-config/config-type-extensions-in-assertions/redocly.yaml @@ -0,0 +1,13 @@ +extends: [] + +plugins: + - plugins/type-extention.js + +rules: + spec: warn + rule/metadata-lifecycle: + subject: + type: XMetaData + property: 'lifecycle' + assertions: + enum: ['alpha', 'beta', 'production', 'deprecated'] diff --git a/__tests__/check-config/config-type-extensions-in-assertions/snapshot.js b/__tests__/check-config/config-type-extensions-in-assertions/snapshot.js new file mode 100644 index 000000000..77d1c145a --- /dev/null +++ b/__tests__/check-config/config-type-extensions-in-assertions/snapshot.js @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E check-config config type extension in assertions 1`] = ` + +✅ Your config is valid. + +`; diff --git a/__tests__/check-config/wrong-config-type-extensions-in-assertions/openapi.yaml b/__tests__/check-config/wrong-config-type-extensions-in-assertions/openapi.yaml new file mode 100644 index 000000000..b38dfc95b --- /dev/null +++ b/__tests__/check-config/wrong-config-type-extensions-in-assertions/openapi.yaml @@ -0,0 +1,7 @@ +openapi: 3.1.0 +info: + title: Food Empire API + version: 0.5.1 + x-metadata: + lifecycle: production + owner-team: Engineering/Integrations diff --git a/__tests__/check-config/wrong-config-type-extensions-in-assertions/plugins/type-extention.js b/__tests__/check-config/wrong-config-type-extensions-in-assertions/plugins/type-extention.js new file mode 100644 index 000000000..7dca3c6a9 --- /dev/null +++ b/__tests__/check-config/wrong-config-type-extensions-in-assertions/plugins/type-extention.js @@ -0,0 +1,27 @@ +const XMetaData = { + properties: { + lifecycle: { type: 'string', enum: ['development', 'staging', 'production'] }, + 'owner-team': { type: 'string' }, + }, + required: ['lifecycle'], +}; + +module.exports = { + id: 'type-extension', + typeExtension: { + oas3(types) { + newTypes = { + ...types, + XMetaData: XMetaData, + Info: { + ...types.Info, + properties: { + ...types.Info.properties, + 'x-metadata': 'XMetaData', + }, + }, + }; + return newTypes; + }, + }, +}; diff --git a/__tests__/check-config/wrong-config-type-extensions-in-assertions/redocly.yaml b/__tests__/check-config/wrong-config-type-extensions-in-assertions/redocly.yaml new file mode 100644 index 000000000..f12a33c1b --- /dev/null +++ b/__tests__/check-config/wrong-config-type-extensions-in-assertions/redocly.yaml @@ -0,0 +1,13 @@ +extends: [] + +plugins: + - plugins/type-extention.js + +rules: + spec: warn + rule/metadata-lifecycle: + subject: + type: WrongXMetaData + property: 'lifecycle' + assertions: + enum: ['alpha', 'beta', 'production', 'deprecated'] diff --git a/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js b/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js new file mode 100644 index 000000000..9791b0035 --- /dev/null +++ b/__tests__/check-config/wrong-config-type-extensions-in-assertions/snapshot.js @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E check-config wrong config type extension in assertions 1`] = ` + +[1] redocly.yaml:10:13 at #/rules/rule~1metadata-lifecycle/subject/type + +\`type\` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "XMetaData", "NamedPathItems", "ServerMap", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "NamedStreamHeaders", "SecuritySchemeFlows", "Message", "MessageBindings", "OperationBindings", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "CorrelationId", "SpecExtension". + + 8 | rule/metadata-lifecycle: + 9 | subject: +10 | type: WrongXMetaData + | ^^^^^^^^^^^^^^ +11 | property: 'lifecycle' +12 | assertions: + +Error was generated by the configuration spec rule. + + +❌ Your config has 1 error. + + +`; diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts index af65decb2..cf53ed931 100644 --- a/__tests__/commands.test.ts +++ b/__tests__/commands.test.ts @@ -90,6 +90,30 @@ describe('E2E', () => { const result = getCommandOutput(passedArgs, folderPath); (expect(result) as any).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); }); + + test('config type extension in assertions', () => { + const dirName = 'config-type-extensions-in-assertions'; + const folderPath = join(__dirname, `check-config/${dirName}`); + + const passedArgs = getParams('../../../packages/cli/src/index.ts', 'check-config', [ + '--config=redocly.yaml', + ]); + + const result = getCommandOutput(passedArgs, folderPath); + (expect(result) as any).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); + }); + + test('wrong config type extension in assertions', () => { + const dirName = 'wrong-config-type-extensions-in-assertions'; + const folderPath = join(__dirname, `check-config/${dirName}`); + + const passedArgs = getParams('../../../packages/cli/src/index.ts', 'check-config', [ + '--config=redocly.yaml', + ]); + + const result = getCommandOutput(passedArgs, folderPath); + (expect(result) as any).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); + }); }); describe('lint-config', () => { diff --git a/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js b/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js index 1bf7a3098..fbcfb50c8 100644 --- a/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js +++ b/__tests__/lint-config/invalid-config-assertation-config-type/snapshot.js @@ -2,9 +2,11 @@ exports[`E2E lint-config test with option: { dirName: 'invalid-config-assertation-config-type', option: 'warn' } 1`] = ` + +The 'assert/' syntax in assert/path-item-mutually-required is deprecated. Update your configuration to use 'rule/' instead. Examples and more information: https://redocly.com/docs/cli/rules/configurable-rules/ [1] .redocly.yaml:9:17 at #/rules/assert~1path-item-mutually-required/where/0/subject/type -\`type\` can be one of the following only: "any", "Root", "Tag", "TagList", "ExternalDocs", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Paths", "PathItem", "Parameter", "ParameterList", "ParameterItems", "Operation", "Example", "ExamplesMap", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "TagGroup", "TagGroups", "EnumDescriptions", "Logo", "XCodeSample", "XCodeSampleList", "XServer", "XServerList", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "LinksMap", "DiscriminatorMapping", "Discriminator", "Components", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "NamedPathItems", "Message", "SpecExtension". +\`type\` can be one of the following only: "any", "Root", "Tag", "TagList", "TagGroups", "TagGroup", "ExternalDocs", "Example", "ExamplesMap", "EnumDescriptions", "SecurityRequirement", "SecurityRequirementList", "Info", "Contact", "License", "Logo", "Paths", "PathItem", "Parameter", "ParameterItems", "ParameterList", "Operation", "Examples", "Header", "Responses", "Response", "Schema", "Xml", "SchemaProperties", "NamedSchemas", "NamedResponses", "NamedParameters", "NamedSecuritySchemes", "SecurityScheme", "XCodeSample", "XCodeSampleList", "XServerList", "XServer", "Server", "ServerList", "ServerVariable", "ServerVariablesMap", "Callback", "CallbacksMap", "RequestBody", "MediaTypesMap", "MediaType", "Encoding", "EncodingMap", "HeadersMap", "Link", "DiscriminatorMapping", "Discriminator", "Components", "LinksMap", "NamedExamples", "NamedRequestBodies", "NamedHeaders", "NamedLinks", "NamedCallbacks", "ImplicitFlow", "PasswordFlow", "ClientCredentials", "AuthorizationCode", "OAuth2Flows", "XUsePkce", "WebhooksMap", "NamedPathItems", "ServerMap", "HttpServerBinding", "HttpChannelBinding", "HttpMessageBinding", "HttpOperationBinding", "WsServerBinding", "WsChannelBinding", "WsMessageBinding", "WsOperationBinding", "KafkaServerBinding", "KafkaTopicConfiguration", "KafkaChannelBinding", "KafkaMessageBinding", "KafkaOperationBinding", "AnypointmqServerBinding", "AnypointmqChannelBinding", "AnypointmqMessageBinding", "AnypointmqOperationBinding", "AmqpServerBinding", "AmqpChannelBinding", "AmqpMessageBinding", "AmqpOperationBinding", "Amqp1ServerBinding", "Amqp1ChannelBinding", "Amqp1MessageBinding", "Amqp1OperationBinding", "MqttServerBindingLastWill", "MqttServerBinding", "MqttChannelBinding", "MqttMessageBinding", "MqttOperationBinding", "Mqtt5ServerBinding", "Mqtt5ChannelBinding", "Mqtt5MessageBinding", "Mqtt5OperationBinding", "NatsServerBinding", "NatsChannelBinding", "NatsMessageBinding", "NatsOperationBinding", "JmsServerBinding", "JmsChannelBinding", "JmsMessageBinding", "JmsOperationBinding", "SolaceServerBinding", "SolaceChannelBinding", "SolaceMessageBinding", "SolaceDestination", "SolaceOperationBinding", "StompServerBinding", "StompChannelBinding", "StompMessageBinding", "StompOperationBinding", "RedisServerBinding", "RedisChannelBinding", "RedisMessageBinding", "RedisOperationBinding", "MercureServerBinding", "MercureChannelBinding", "MercureMessageBinding", "MercureOperationBinding", "ServerBindings", "ChannelBindings", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "NamedStreamHeaders", "SecuritySchemeFlows", "Message", "MessageBindings", "OperationBindings", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "CorrelationId", "SpecExtension". 7 | where: 8 | - subject: @@ -17,8 +19,6 @@ Warning was generated by the configuration spec rule. ⚠️ Your config has 1 warning. - -The 'assert/' syntax in assert/path-item-mutually-required is deprecated. Update your configuration to use 'rule/' instead. Examples and more information: https://redocly.com/docs/cli/rules/configurable-rules/ validating ../__fixtures__/valid-openapi.yaml... ../__fixtures__/valid-openapi.yaml: validated in ms diff --git a/__tests__/lint/deprecated-apiDefinitions/snapshot.js b/__tests__/lint/deprecated-apiDefinitions/snapshot.js index d5505a7f0..be7a72086 100644 --- a/__tests__/lint/deprecated-apiDefinitions/snapshot.js +++ b/__tests__/lint/deprecated-apiDefinitions/snapshot.js @@ -2,6 +2,8 @@ exports[`E2E lint deprecated-apiDefinitions 1`] = ` +The 'apiDefinitions' field is deprecated. Use apis instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties +The 'lint' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties [1] redocly.yaml:1:1 at #/apiDefinitions Property \`apiDefinitions\` is not expected here. @@ -31,8 +33,6 @@ Warning was generated by the configuration spec rule. ⚠️ Your config has 2 warnings. -The 'apiDefinitions' field is deprecated. Use apis instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties -The 'lint' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties validating /openapi.yaml... [1] openapi.yaml:2:1 at #/info/contact diff --git a/__tests__/lint/deprecated-lint/snapshot.js b/__tests__/lint/deprecated-lint/snapshot.js index b04ab316c..570aa4ea2 100644 --- a/__tests__/lint/deprecated-lint/snapshot.js +++ b/__tests__/lint/deprecated-lint/snapshot.js @@ -2,6 +2,7 @@ exports[`E2E lint deprecated-lint 1`] = ` +The 'lint' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties [1] redocly.yaml:8:1 at #/lint Property \`lint\` is not expected here. @@ -35,7 +36,6 @@ Warning was generated by the configuration spec rule. ⚠️ Your config has 2 warnings. -The 'lint' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties validating /openapi.yaml... [1] openapi.yaml:11:7 at #/paths/~1pet~1findByStatus/get/responses diff --git a/__tests__/lint/deprecated-styleguide/snapshot.js b/__tests__/lint/deprecated-styleguide/snapshot.js index 062273994..ae63d5466 100644 --- a/__tests__/lint/deprecated-styleguide/snapshot.js +++ b/__tests__/lint/deprecated-styleguide/snapshot.js @@ -2,6 +2,7 @@ exports[`E2E lint deprecated-styleguide 1`] = ` +The 'styleguide' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties [1] redocly.yaml:8:1 at #/styleguide Property \`styleguide\` is not expected here. @@ -31,7 +32,6 @@ Warning was generated by the configuration spec rule. ⚠️ Your config has 2 warnings. -The 'styleguide' field is deprecated. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties validating /openapi.yaml... [1] openapi.yaml:11:7 at #/paths/~1pet~1findByStatus/get/responses diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 3eb41b85b..d7df1beb2 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -21,8 +21,8 @@ import { import { blue, gray } from 'colorette'; import { performance } from 'perf_hooks'; -import type { OutputFormat, ProblemSeverity, Document, RuleSeverity } from '@redocly/openapi-core'; -import type { ResolvedRefMap } from '@redocly/openapi-core/lib/resolve'; +import type { OutputFormat, ProblemSeverity, RuleSeverity } from '@redocly/openapi-core'; +import type { RawConfigProcessor } from '@redocly/openapi-core/lib/config'; import type { CommandOptions, Skips, Totals } from '../types'; import { getCommandNameFromArgs } from '../utils/getCommandNameFromArgs'; import { Arguments } from 'yargs'; @@ -120,7 +120,7 @@ export async function handleLint(argv: LintOptions, config: Config, version: str export function lintConfigCallback( argv: CommandOptions & Record, version: string -) { +): RawConfigProcessor | undefined { if (argv['lint-config'] === 'off') { return; } @@ -130,10 +130,11 @@ export function lintConfigCallback( return; } - return async (document: Document, resolvedRefMap: ResolvedRefMap) => { + return async ({ document, resolvedRefMap, config }) => { const problems = await lintConfig({ document, resolvedRefMap, + config, severity: (argv['lint-config'] || 'warn') as ProblemSeverity, }); diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts index a585a0d2e..89c54a51c 100644 --- a/packages/core/src/__tests__/lint.test.ts +++ b/packages/core/src/__tests__/lint.test.ts @@ -3,7 +3,7 @@ import { outdent } from 'outdent'; import { lintFromString, lintConfig, lintDocument, lint } from '../lint'; import { BaseResolver } from '../resolve'; -import { loadConfig } from '../config/load'; +import { createConfig, loadConfig } from '../config/load'; import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../__tests__/utils'; import { detectSpec } from '../oas-types'; import { rootRedoclyConfigSchema } from '@redocly/config'; @@ -293,7 +293,7 @@ describe('lint', () => { - url: http://redocly-example.com paths: {} `, - config: await loadConfig(), + config: await loadConfig({ configPath: path.join(__dirname, 'fixtures/redocly.yaml') }), }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` @@ -374,7 +374,8 @@ describe('lint', () => { `, '' ); - const results = await lintConfig({ document }); + const config = await createConfig({}); + const results = await lintConfig({ document, config }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` [ @@ -435,7 +436,8 @@ describe('lint', () => { `, '' ); - const results = await lintConfig({ document }); + const config = await createConfig({}); + const results = await lintConfig({ document, config }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` [ @@ -475,7 +477,8 @@ describe('lint', () => { `, '' ); - const results = await lintConfig({ document }); + const config = await createConfig({}); + const results = await lintConfig({ document, config }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` [ @@ -510,7 +513,8 @@ describe('lint', () => { `, '' ); - const results = await lintConfig({ document }); + const config = await createConfig({}); + const results = await lintConfig({ document, config }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` [ @@ -534,7 +538,8 @@ describe('lint', () => { it('lintConfig should detect wrong fields in the default configuration after merging with the portal config schema', async () => { const document = testPortalConfig; - const results = await lintConfig({ document }); + const config = await createConfig({}); + const results = await lintConfig({ document, config }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` [ @@ -1165,13 +1170,18 @@ describe('lint', () => { it('lintConfig should alternate its behavior when supplied externalConfigTypes', async () => { const document = testPortalConfig; + const config = await createConfig({}); const results = await lintConfig({ document, - externalConfigTypes: createConfigTypes({ - type: 'object', - properties: { theme: rootRedoclyConfigSchema.properties.theme }, - additionalProperties: false, - }), + externalConfigTypes: createConfigTypes( + { + type: 'object', + properties: { theme: rootRedoclyConfigSchema.properties.theme }, + additionalProperties: false, + }, + config + ), + config, }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index 52cad6b81..9776aba38 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -58,6 +58,88 @@ describe('loadConfig', () => { expect(mockFn).toHaveBeenCalled(); }); + it('should resolve config and call processRawConfig', async () => { + let problems: NormalizedProblem[]; + let doc: any; + + await loadConfig({ + configPath: path.join(__dirname, './fixtures/resolve-refs-in-config/config-with-refs.yaml'), + processRawConfig: async ({ document, parsed, resolvedRefMap, config }) => { + doc = parsed; + problems = await lintConfig({ + document, + severity: 'warn', + resolvedRefMap, + config, + }); + }, + }); + + expect(replaceSourceWithRef(problems!, __dirname)).toMatchInlineSnapshot(` + [ + { + "from": { + "pointer": "#/seo", + "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml", + }, + "location": [ + { + "pointer": "#/title", + "reportOnKey": false, + "source": "fixtures/resolve-refs-in-config/seo.yaml", + }, + ], + "message": "Expected type \`string\` but got \`integer\`.", + "ruleId": "configuration spec", + "severity": "warn", + "suggest": [], + }, + { + "from": { + "pointer": "#/rules", + "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml", + }, + "location": [ + { + "pointer": "#/non-existing-rule", + "reportOnKey": true, + "source": "fixtures/resolve-refs-in-config/rules.yaml", + }, + ], + "message": "Property \`non-existing-rule\` is not expected here.", + "ruleId": "configuration spec", + "severity": "warn", + "suggest": [], + }, + { + "location": [ + { + "pointer": "#/theme", + "reportOnKey": false, + "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml", + }, + ], + "message": "Can't resolve $ref: ENOENT: no such file or directory 'fixtures/resolve-refs-in-config/wrong-ref.yaml'", + "ruleId": "configuration no-unresolved-refs", + "severity": "warn", + "suggest": [], + }, + ] + `); + expect(doc).toMatchInlineSnapshot(` + { + "rules": { + "info-license": "error", + "non-existing-rule": "warn", + }, + "seo": { + "title": 1, + }, + "theme": undefined, + } + `); + }); + it('should call externalRefResolver if such passed', async () => { const externalRefResolver = new BaseResolver(); const resolverSpy = jest.spyOn(externalRefResolver, 'resolveDocument'); @@ -104,22 +186,16 @@ describe('findConfig', () => { describe('getConfig', () => { jest.spyOn(fs, 'hasOwnProperty').mockImplementation(() => false); it('should return empty object if there is no configPath and config file is not found', () => { - expect(getConfig()).toEqual(Promise.resolve({})); + expect(getConfig()).toEqual(Promise.resolve({ rawConfig: {} })); }); it('should resolve refs in config', async () => { let problems: NormalizedProblem[]; - const result = await getConfig({ + + const { rawConfig } = await getConfig({ configPath: path.join(__dirname, './fixtures/resolve-refs-in-config/config-with-refs.yaml'), - processRawConfig: async (config, resolvedRefMap) => { - problems = await lintConfig({ - document: config, - severity: 'warn', - resolvedRefMap, - }); - }, }); - expect(result).toEqual({ + expect(rawConfig).toEqual({ seo: { title: 1, }, @@ -130,57 +206,6 @@ describe('getConfig', () => { }, }, }); - expect(replaceSourceWithRef(problems!, __dirname)).toMatchInlineSnapshot(` - [ - { - "from": { - "pointer": "#/seo", - "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml", - }, - "location": [ - { - "pointer": "#/title", - "reportOnKey": false, - "source": "fixtures/resolve-refs-in-config/seo.yaml", - }, - ], - "message": "Expected type \`string\` but got \`integer\`.", - "ruleId": "configuration spec", - "severity": "warn", - "suggest": [], - }, - { - "from": { - "pointer": "#/rules", - "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml", - }, - "location": [ - { - "pointer": "#/non-existing-rule", - "reportOnKey": true, - "source": "fixtures/resolve-refs-in-config/rules.yaml", - }, - ], - "message": "Property \`non-existing-rule\` is not expected here.", - "ruleId": "configuration spec", - "severity": "warn", - "suggest": [], - }, - { - "location": [ - { - "pointer": "#/theme", - "reportOnKey": false, - "source": "fixtures/resolve-refs-in-config/config-with-refs.yaml", - }, - ], - "message": "Can't resolve $ref: ENOENT: no such file or directory 'fixtures/resolve-refs-in-config/wrong-ref.yaml'", - "ruleId": "configuration no-unresolved-refs", - "severity": "warn", - "suggest": [], - }, - ] - `); }); }); diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts index 56082c441..c5e2d4d34 100644 --- a/packages/core/src/config/load.ts +++ b/packages/core/src/config/load.ts @@ -4,7 +4,7 @@ import { RedoclyClient } from '../redocly'; import { isEmptyObject } from '../utils'; import { parseYaml } from '../js-yaml'; import { Config } from './config'; -import { ConfigValidationError, transformConfig } from './utils'; +import { ConfigValidationError, transformConfig, deepCloneMapWithJSON } from './utils'; import { resolveConfig, resolveConfigFileAndRefs } from './config-resolvers'; import { bundleConfig } from '../bundle'; import { BaseResolver } from '../resolve'; @@ -80,10 +80,12 @@ async function addConfigMetadata({ }); } -export type RawConfigProcessor = ( - rawConfig: Document, - resolvedRefMap: ResolvedRefMap -) => void | Promise; +export type RawConfigProcessor = (params: { + document: Document; + resolvedRefMap: ResolvedRefMap; + config: Config; + parsed: Document['parsed']; +}) => void | Promise; export async function loadConfig( options: { @@ -103,12 +105,16 @@ export async function loadConfig( region, externalRefResolver, } = options; - const rawConfig = await getConfig({ configPath, processRawConfig, externalRefResolver }); + + const { rawConfig, document, parsed, resolvedRefMap } = await getConfig({ + configPath, + externalRefResolver, + }); const redoclyClient = isBrowser ? undefined : new RedoclyClient(); const tokens = redoclyClient && redoclyClient.hasTokens() ? redoclyClient.getAllTokens() : []; - return addConfigMetadata({ + const config = await addConfigMetadata({ rawConfig, customExtends, configPath, @@ -117,6 +123,24 @@ export async function loadConfig( region, externalRefResolver, }); + + if (document && parsed && resolvedRefMap && typeof processRawConfig === 'function') { + try { + await processRawConfig({ + document, + resolvedRefMap, + config, + parsed, + }); + } catch (e) { + if (e instanceof ConfigValidationError) { + throw e; + } + throw new Error(`Error parsing config file at '${configPath}': ${e.message}`); + } + } + + return config; } export const CONFIG_FILE_NAMES = ['redocly.yaml', 'redocly.yml', '.redocly.yaml', '.redocly.yml']; @@ -139,31 +163,33 @@ export function findConfig(dir?: string): string | undefined { export async function getConfig( options: { configPath?: string; - processRawConfig?: RawConfigProcessor; externalRefResolver?: BaseResolver; } = {} -): Promise { - const { - configPath = findConfig(), - processRawConfig, - externalRefResolver = new BaseResolver(), - } = options; - if (!configPath) return {}; +): Promise<{ + rawConfig: RawConfig; + document?: Document; + parsed?: Document['parsed']; + resolvedRefMap?: ResolvedRefMap; +}> { + const { configPath = findConfig(), externalRefResolver = new BaseResolver() } = options; + if (!configPath) return { rawConfig: {} }; try { const { document, resolvedRefMap } = await resolveConfigFileAndRefs({ configPath, externalRefResolver, }); - if (typeof processRawConfig === 'function') { - await processRawConfig(document, resolvedRefMap); - } - const bundledConfig = await bundleConfig(document, resolvedRefMap); - return transformConfig(bundledConfig); + + const bundledRefMap = deepCloneMapWithJSON(resolvedRefMap); + const parsed = await bundleConfig(JSON.parse(JSON.stringify(document)), bundledRefMap); + + return { + rawConfig: transformConfig(parsed), + document, + parsed, + resolvedRefMap, + }; } catch (e) { - if (e instanceof ConfigValidationError) { - throw e; - } throw new Error(`Error parsing config file at '${configPath}': ${e.message}`); } } diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index 137481a70..cb86144f1 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -364,3 +364,7 @@ export function getUniquePlugins(plugins: Plugin[]): Plugin[] { } export class ConfigValidationError extends Error {} + +export function deepCloneMapWithJSON(originalMap: Map): Map { + return new Map(JSON.parse(JSON.stringify([...originalMap]))); +} diff --git a/packages/core/src/lint.ts b/packages/core/src/lint.ts index ceff0a97d..ee29ac499 100755 --- a/packages/core/src/lint.ts +++ b/packages/core/src/lint.ts @@ -1,11 +1,11 @@ import { BaseResolver, resolveDocument, makeDocumentFromString } from './resolve'; import { normalizeVisitors } from './visitors'; import { walkDocument } from './walk'; -import { StyleguideConfig, Config, initRules, defaultPlugin, resolvePlugins } from './config'; +import { StyleguideConfig, Config, initRules } from './config'; import { normalizeTypes } from './types'; import { releaseAjvInstance } from './rules/ajv'; import { SpecVersion, getMajorSpecVersion, detectSpec, getTypes } from './oas-types'; -import { ConfigTypes } from './types/redocly-yaml'; +import { createConfigTypes } from './types/redocly-yaml'; import { Spec } from './rules/common/spec'; import { NoUnresolvedRefs } from './rules/no-unresolved-refs'; @@ -13,6 +13,7 @@ import type { Document, ResolvedRefMap } from './resolve'; import type { ProblemSeverity, WalkContext } from './walk'; import type { NodeType } from './types'; import type { NestedVisitObject, Oas3Visitor, RuleInstanceConfig } from './visitors'; +import { rootRedoclyConfigSchema } from '@redocly/config'; export async function lint(opts: { ref: string; @@ -109,25 +110,25 @@ export async function lintDocument(opts: { export async function lintConfig(opts: { document: Document; + config: Config; resolvedRefMap?: ResolvedRefMap; severity?: ProblemSeverity; externalRefResolver?: BaseResolver; externalConfigTypes?: Record; }) { - const { document, severity, externalRefResolver = new BaseResolver() } = opts; + const { document, severity, externalRefResolver = new BaseResolver(), config } = opts; const ctx: WalkContext = { problems: [], oasVersion: SpecVersion.OAS3_0, visitorsData: {}, }; - const plugins = resolvePlugins([defaultPlugin]); - const config = new StyleguideConfig({ - plugins, - rules: { spec: 'error' }, - }); - const types = normalizeTypes(opts.externalConfigTypes || ConfigTypes, config); + const types = normalizeTypes( + opts.externalConfigTypes || createConfigTypes(rootRedoclyConfigSchema, config), + { doNotResolveExamples: config.styleguide.doNotResolveExamples } + ); + const rules: (RuleInstanceConfig & { visitor: NestedVisitObject; })[] = [ diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 7d99b1502..4f4cd9959 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -5,6 +5,8 @@ import { getNodeTypesFromJSONSchema } from './json-schema-adapter'; import type { NodeType } from '.'; import type { JSONSchema } from 'json-schema-to-ts'; +import { SpecVersion, getTypes } from '../oas-types'; +import { Config } from '../config'; const builtInCommonRules = [ 'spec', @@ -222,8 +224,6 @@ const oas3_1NodeTypesList = [ export type Oas3_1NodeType = typeof oas3_1NodeTypesList[number]; -const asyncNodeTypesList = ['Message'] as const; - const ConfigStyleguide: NodeType = { properties: { extends: { @@ -350,35 +350,28 @@ const Schema: NodeType = { additionalProperties: {}, }; -const AssertionDefinitionSubject: NodeType = { - properties: { - type: { - enum: [ - ...new Set([ - 'any', - ...oas2NodeTypesList, - ...oas3NodeTypesList, - ...oas3_1NodeTypesList, - ...asyncNodeTypesList, - 'SpecExtension', - ]), - ], - }, - property: (value: unknown) => { - if (Array.isArray(value)) { - return { type: 'array', items: { type: 'string' } }; - } else if (value === null) { - return null; - } else { - return { type: 'string' }; - } +function createAssertionDefinitionSubject(nodeNames: string[]): NodeType { + return { + properties: { + type: { + enum: [...new Set(['any', ...nodeNames, 'SpecExtension'])], + }, + property: (value: unknown) => { + if (Array.isArray(value)) { + return { type: 'array', items: { type: 'string' } }; + } else if (value === null) { + return null; + } else { + return { type: 'string' }; + } + }, + filterInParentKeys: { type: 'array', items: { type: 'string' } }, + filterOutParentKeys: { type: 'array', items: { type: 'string' } }, + matchParentKeys: { type: 'string' }, }, - filterInParentKeys: { type: 'array', items: { type: 'string' } }, - filterOutParentKeys: { type: 'array', items: { type: 'string' } }, - matchParentKeys: { type: 'string' }, - }, - required: ['type'], -}; + required: ['type'], + }; +} const AssertionDefinitionAssertions: NodeType = { properties: { @@ -1057,7 +1050,13 @@ const ConfigMockServer: NodeType = { }, }; -export const createConfigTypes = (extraSchemas: JSONSchema) => { +export function createConfigTypes(extraSchemas: JSONSchema, config?: Config) { + const nodeNames = Object.values(SpecVersion).flatMap((version) => { + const types = config?.styleguide + ? config.styleguide.extendTypes(getTypes(version), version) + : getTypes(version); + return Object.keys(types); + }); // Create types based on external schemas const nodeTypes = getNodeTypesFromJSONSchema('rootRedoclyConfigSchema', extraSchemas); @@ -1065,9 +1064,10 @@ export const createConfigTypes = (extraSchemas: JSONSchema) => { ...CoreConfigTypes, ConfigRoot: createConfigRoot(nodeTypes), // This is the REAL config root type ConfigApisProperties: createConfigApisProperties(nodeTypes), + AssertionDefinitionSubject: createAssertionDefinitionSubject(nodeNames), ...nodeTypes, }; -}; +} const CoreConfigTypes: Record = { Assert, @@ -1130,7 +1130,6 @@ const CoreConfigTypes: Record = { Heading, Typography, AssertionDefinitionAssertions, - AssertionDefinitionSubject, }; export const ConfigTypes: Record = createConfigTypes(rootRedoclyConfigSchema);