Repository: Redocly/redocly-cli
Pull request #724 (2022-06-06 11:03:47)

fix: improve types for config
## What/Why/How?
Improve config types for VS Code and CLI validation.

## Reference
Connected to #720

## Testing

## Screenshots (optional)

## Check yourself

- [ ] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action at  -->

## Coverage report 


### Total coverage

| Status | Category   | Percentage | Covered / Total |
| :----: | :--------- | :--------- | :-------------: |
|   🟡   | Statements | 66.01%     |    2655/4022    |
|   🟡   | Branches   | 60%        |    1452/2420    |
|   🔴   | Functions  | 58.39%     |     428/733     |
|   🟡   | Lines      | 66.26%     |    2457/3708    |

> Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold



<details><summary>Show files with reduced coverage 🔻</summary>


### Reduced coverage

| Status | Filename                                                                         | Statements       | Branches      | Functions | Lines            |
| :----: | :------------------------------------------------------------------------------- | :--------------- | :------------ | :-------- | :--------------- |
|   🟢   | home/runner/work/redocly-cli/redocly-cli/packages/core/src/types/redocly-yaml.ts | 92.5% (-7.5% 🔻) | 50% (-50% 🔻) | 100%      | 92.5% (-7.5% 🔻) |

> Status of coverage: 🟢 - ok, 🟡 - slightly more than threshold, 🔴 - under the threshold


</details>


<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 3152d07b07496aea7d252901ce3f7885c50bd85c</p>


Patch:
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index d191f49d..f84f1aa6 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -1,13 +1,124 @@
 import { NodeType, listOf } from '.';
-import { omitObjectProps, pickObjectProps } from '../utils';
+import { omitObjectProps, pickObjectProps, isCustomRuleId } from '../utils';
+const builtInRulesList = [
+  'spec',
+  'info-description',
+  'info-contact',
+  'info-license',
+  'info-license-url',
+  'operation-2xx-response',
+  'operation-4xx-response',
+  'assertions',
+  'operation-operationId-unique',
+  'operation-parameters-unique',
+  'path-parameters-defined',
+  'operation-tag-defined',
+  'no-example-value-and-externalValue',
+  'no-enum-type-mismatch',
+  'no-path-trailing-slash',
+  'no-empty-servers',
+  'path-declaration-must-exist',
+  'operation-operationId-url-safe',
+  'operation-operationId',
+  'operation-summary',
+  'tags-alphabetical',
+  'no-server-example.com',
+  'no-server-trailing-slash',
+  'tag-description',
+  'operation-description',
+  'no-unused-components',
+  'path-not-include-query',
+  'path-params-defined',
+  'parameter-description',
+  'operation-singular-tag',
+  'operation-security-defined',
+  'no-unresolved-refs',
+  'paths-kebab-case',
+  'boolean-parameter-prefixes',
+  'path-http-verbs-order',
+  'no-invalid-media-type-examples',
+  'no-identical-paths',
+  'no-ambiguous-paths',
+  'no-undefined-server-variable',
+  'no-servers-empty-enum',
+  'no-http-verbs-in-paths',
+  'path-excludes-patterns',
+  'request-mime-type',
+  'response-mime-type',
+  'path-segment-plural',
+  'no-invalid-schema-examples',
+  'no-invalid-parameter-examples',
+  'response-contains-header',
+  'response-contains-property',
+];
+const nodeTypesList = [
+  'DefinitionRoot',
+  'Tag',
+  'ExternalDocs',
+  'Server',
+  'ServerVariable',
+  'SecurityRequirement',
+  'Info',
+  'Contact',
+  'License',
+  'PathMap',
+  'PathItem',
+  'Parameter',
+  'Operation',
+  'Callback',
+  'RequestBody',
+  'MediaTypeMap',
+  'MediaType',
+  'Example',
+  'Encoding',
+  'Header',
+  'ResponsesMap',
+  'Response',
+  'Link',
+  'Schema',
+  'Xml',
+  'SchemaProperties',
+  'DiscriminatorMapping',
+  'Discriminator',
+  'Components',
+  'NamedSchemas',
+  'NamedResponses',
+  'NamedParameters',
+  'NamedExamples',
+  'NamedRequestBodies',
+  'NamedHeaders',
+  'NamedSecuritySchemes',
+  'NamedLinks',
+  'NamedCallbacks',
+  'ImplicitFlow',
+  'PasswordFlow',
+  'ClientCredentials',
+  'AuthorizationCode',
+  'SecuritySchemeFlows',
+  'SecurityScheme',
+  'XCodeSample',
+  'WebhooksMap',
+];
 
 const ConfigRoot: NodeType = {
   properties: {
     organization: { type: 'string' },
     apis: 'ConfigApis',
+    apiDefinitions: {
+      type: 'object',
+      properties: {},
+      additionalProperties: { properties: { type: 'string' } }
+    }, // deprecated
     lint: 'RootConfigLint',
     'features.openapi': 'ConfigReferenceDocs',
+    referenceDocs: 'ConfigReferenceDocs', // deprecated
     'features.mockServer': 'ConfigMockServer',
+    region: { enum: ['us', 'eu'] },
+    resolve: {
+      properties: {
+        http: 'ConfigHTTP',
+      },
+    },
   },
 };
 
@@ -19,9 +130,17 @@ const ConfigApis: NodeType = {
 const ConfigApisProperties: NodeType = {
   properties: {
     root: { type: 'string' },
+    labels: {
+      type: 'array',
+      items: {
+        type: 'string',
+      },
+    },
     lint: 'ConfigLint',
     'features.openapi': 'ConfigReferenceDocs',
+    'features.mockServer': 'ConfigMockServer',
   },
+  required: ['root'],
 };
 
 const ConfigHTTP: NodeType = {
@@ -44,10 +163,10 @@ const ConfigLint: NodeType = {
       },
     },
     doNotResolveExamples: { type: 'boolean' },
-    rules: { type: 'object' },
-    oas2Rules: { type: 'object' },
-    oas3_0Rules: { type: 'object' },
-    oas3_1Rules: { type: 'object' },
+    rules: 'Rules',
+    oas2Rules: 'Rules',
+    oas3_0Rules: 'Rules',
+    oas3_1Rules: 'Rules',
     preprocessors: { type: 'object' },
     oas2Preprocessors: { type: 'object' },
     oas3_0Preprocessors: { type: 'object' },
@@ -56,11 +175,6 @@ const ConfigLint: NodeType = {
     oas2Decorators: { type: 'object' },
     oas3_0Decorators: { type: 'object' },
     oas3_1Decorators: { type: 'object' },
-    resolve: {
-      properties: {
-        http: 'ConfigHTTP',
-      },
-    },
   },
 };
 
@@ -74,11 +188,87 @@ const RootConfigLint: NodeType = {
   },
 };
 
+const Rules: NodeType = {
+  properties: {},
+  additionalProperties: (value: unknown, key: string) => {
+    if (key.startsWith('assert/')) {
+      return 'Assert';
+    } else if (builtInRulesList.includes(key) || isCustomRuleId(key)) {
+      if (typeof value === 'string') {
+        return { enum: ['error', 'warn', 'off'] };
+      } else {
+        return 'ObjectRule';
+      }
+    }
+    // Otherwise is considered as invalid
+    return;
+  },
+};
+
+const ObjectRule: NodeType = {
+  properties: {
+    severity: { enum: ['error', 'warn', 'off'] },
+  },
+  additionalProperties: {},
+  required: ['severity'],
+};
+
+const Assert: NodeType = {
+  properties: {
+    subject: (value: unknown) => {
+      if (Array.isArray(value)) {
+        return { type: 'array', items: { enum: nodeTypesList } };
+      } else {
+        return { enum: nodeTypesList };
+      }
+    },
+    property: (value: unknown) => {
+      if (Array.isArray(value)) {
+        return { type: 'array', items: { type: 'string' } };
+      } else if (value === null) {
+        return null;
+      } else {
+        return { type: 'string' };
+      }
+    },
+    context: 'Context',
+    message: { type: 'string' },
+    suggest: { type: 'array', items: { type: 'string' } },
+    severity: { enum: ['error', 'warn', 'off'] },
+    enum: { type: 'array', items: { type: 'string' } },
+    pattern: { type: 'string' },
+    casing: { enum: ['camelCase', 'kebab-case', 'snake_case', 'PascalCase', 'MACRO_CASE', 'COBOL-CASE', 'flatcase'] },
+    mutuallyExclusive: { type: 'array', items: { type: 'string' } },
+    mutuallyRequired: { type: 'array', items: { type: 'string' } },
+    required: { type: 'array', items: { type: 'string' } },
+    requireAny: { type: 'array', items: { type: 'string' } },
+    disallowed: { type: 'array', items: { type: 'string' } },
+    defined: { type: 'boolean' },
+    undefined: { type: 'boolean' },
+    nonEmpty: { type: 'boolean' },
+    minLength: { type: 'integer' },
+    maxLength: { type: 'integer' },
+  },
+  required: ['subject'],
+};
+
+const Context: NodeType = {
+  properties: {
+    type: { enum: nodeTypesList },
+    matchParentKeys: { type: 'array', items: { type: 'string' } },
+    excludeParentKeys: { type: 'array', items: { type: 'string' } },
+  },
+  required: ['type'],
+};
+
 const ConfigLanguage: NodeType = {
   properties: {
     label: { type: 'string' },
-    lang: { type: 'string' },
+    lang: {
+      enum: ['curl', 'C#', 'Go', 'Java', 'Java8+Apache', 'JavaScript', 'Node.js', 'PHP', 'Python', 'R', 'Ruby'],
+    },
   },
+  required: ['lang'],
 };
 
 const ConfigLabels: NodeType = {
@@ -102,11 +292,18 @@ const ConfigLabels: NodeType = {
 
 const ConfigSidebarLinks: NodeType = {
   properties: {
-    placement: { type: 'string' },
+    beforeInfo: listOf('CommonConfigSidebarLinks'),
+    end: listOf('CommonConfigSidebarLinks'),
+  },
+};
+
+const CommonConfigSidebarLinks: NodeType = {
+  properties: {
     label: { type: 'string' },
     link: { type: 'string' },
     target: { type: 'string' },
   },
+  required: ['label', 'link'],
 };
 
 const CommonThemeColors: NodeType = {
@@ -147,7 +344,7 @@ const HttpColors: NodeType = {
 
 const ResponseColors: NodeType = {
   properties: {
-    errors: 'CommonColorProps',
+    error: 'CommonColorProps',
     info: 'CommonColorProps',
     redirect: 'CommonColorProps',
     success: 'CommonColorProps',
@@ -242,12 +439,15 @@ const HttpBadgesConfig: NodeType = {
 const LabelControls: NodeType = {
   properties: {
     top: { type: 'string' },
+    width: { type: 'string' },
+    height: { type: 'string' },
   },
 };
 
 const Panels: NodeType = {
   properties: {
     borderRadius: { type: 'string' },
+    backgroundColor: { type: 'string' },
   },
 };
 
@@ -285,6 +485,7 @@ const StackedConfig: NodeType = {
 const ThreePanelConfig: NodeType = {
   properties: {
     maxWidth: 'Breakpoints',
+    middlePanelMaxWidth: 'Breakpoints',
   },
 };
 
@@ -378,7 +579,7 @@ const CodeConfig: NodeType = {
     ...FontConfig.properties,
     backgroundColor: { type: 'string' },
     color: { type: 'string' },
-    wordBreak: { type: 'string' },
+    wordBreak: { enum: ['break-all', 'break-word', 'keep-all', 'normal', 'revert', 'unset', 'inherit', 'initial'] },
     wrap: { type: 'boolean' },
   },
 };
@@ -413,7 +614,7 @@ const Typography: NodeType = {
     links: 'LinksConfig',
     optimizeSpeed: { type: 'boolean' },
     rightPanelHeading: 'Heading',
-    smoothing: { type: 'string' },
+    smoothing: { enum: ['auto', 'none', 'antialiased', 'subpixel-antialiased', 'grayscale'] },
   },
 };
 
@@ -440,6 +641,13 @@ const Logo: NodeType = {
   },
 };
 
+const Fab: NodeType = {
+  properties: {
+    backgroundColor: { type: 'string' },
+    color: { type: 'string' },
+  },
+};
+
 const ButtonOverrides: NodeType = {
   properties: {
     custom: { type: 'string' },
@@ -484,6 +692,7 @@ const ConfigTheme: NodeType = {
     components: 'Components',
     layout: 'Layout',
     logo: 'Logo',
+    fab: 'Fab',
     overrides: 'Overrides',
     rightPanel: 'RightPanel',
     schema: 'Schema',
@@ -491,8 +700,8 @@ const ConfigTheme: NodeType = {
     sidebar: 'Sidebar',
     spacing: 'ThemeSpacing',
     typography: 'Typography',
-    links: { properties: { color: { type: 'string' } } },
-    codeSample: { properties: { backgroundColor: { type: 'string' } } },
+    links: { properties: { color: { type: 'string' } } }, // deprecated
+    codeSample: { properties: { backgroundColor: { type: 'string' } } }, // deprecated
   },
 };
 
@@ -501,6 +710,7 @@ const GenerateCodeSamples: NodeType = {
     skipOptionalParameters: { type: 'boolean' },
     languages: listOf('ConfigLanguage'),
   },
+  required: ['languages'],
 };
 
 const ConfigReferenceDocs: NodeType = {
@@ -527,43 +737,77 @@ const ConfigReferenceDocs: NodeType = {
     hideLoading: { type: 'boolean' },
     hideLogo: { type: 'boolean' },
     hideRequestPayloadSample: { type: 'boolean' },
+    hideRightPanel: { type: 'boolean' },
     hideSchemaPattern: { type: 'boolean' },
     hideSchemaTitles: { type: 'boolean' },
     hideSingleRequestSampleTab: { type: 'boolean' },
+    hideSecuritySection: { type: 'boolean' },
     hideTryItPanel: { type: 'boolean' },
     hideFab: { type: 'boolean' },
     hideOneOfDescription: { type: 'boolean'},
     htmlTemplate: { type: 'string' },
-    jsonSampleExpandLevel: { type: 'string' },
+    jsonSampleExpandLevel: (value: unknown) => {
+      if (typeof value === 'number') {
+        return { type: 'number', minimum: 1 };
+      } else {
+        return { type: 'string' };
+      }
+    },
     labels: 'ConfigLabels',
-    layout: { type: 'string' },
+    layout: { enum: ['stacked', 'three-panel'] },
     maxDisplayedEnumValues: { type: 'number' },
     menuToggle: { type: 'boolean' },
     nativeScrollbars: { type: 'boolean' },
-    noAutoAuth: { type: 'boolean' },
+    noAutoAuth: { type: 'boolean' }, // deprecated
     oAuth2RedirectURI: { type: 'string' },
     onDeepLinkClick: { type: 'object' },
     onlyRequiredInSamples: { type: 'boolean' },
-    pagination: { type: 'string' },
+    pagination: { enum: ['none', 'section', 'item'] },
     pathInMiddlePanel: { type: 'boolean' },
-    payloadSampleIdx: { type: 'number' },
+    payloadSampleIdx: { type: 'number', minimum: 0 },
     requestInterceptor: { type: 'object' },
     requiredPropsFirst: { type: 'boolean' },
     routingBasePath: { type: 'string' },
+    routingStrategy: { type: 'string' }, // deprecated
     samplesTabsMaxCount: { type: 'number' },
-    schemaExpansionLevel: { type: 'string' },
+    schemaExpansionLevel: (value: unknown) => {
+      if (typeof value === 'number') {
+        return { type: 'number', minimum: 0 };
+      } else {
+        return { type: 'string' };
+      } 
+    },
     schemaDefinitionsTagName: { type: 'string' },
-    minCharacterLengthToInitSearch: { type: 'number' },
-    maxResponseHeadersToShowInTryIt: {type: 'number'},
-    scrollYOffset: { type: 'string' },
+    minCharacterLengthToInitSearch: { type: 'number', minimum: 1 },
+    maxResponseHeadersToShowInTryIt: {type: 'number', minimum: 0 },
+    scrollYOffset: (value: unknown) => {
+      if (typeof value === 'number') {
+        return { type: 'number' };
+      } else {
+        return { type: 'string' };
+      } 
+    },
     searchAutoExpand: { type: 'boolean' },
-    searchFieldLevelBoost: { type: 'number' },
-    searchMode: { type: 'string' },
+    searchFieldLevelBoost: { type: 'number', minimum: 0 },
+    searchMaxDepth: { type: 'number', minimum: 1 },
+    searchMode: { enum: ['default', 'path-only'] },
     searchOperationTitleBoost: { type: 'number' },
     searchTagTitleBoost: { type: 'number' },
+    sendXUserAgentInTryIt: { type: 'boolean' },
     showChangeLayoutButton: { type: 'boolean' },
-    showConsole: { type: 'boolean' },
-    showExtensions: { type: 'boolean' },
+    showConsole: { type: 'boolean' }, // deprecated
+    showExtensions: (value: unknown) => {
+      if (typeof value === 'boolean') {
+        return { type: 'boolean' };
+      } else {
+        return { 
+          type: 'array',
+          items: {
+            type: 'string',
+          }, 
+        };
+      }
+    },
     showNextButton: { type: 'boolean' },
     showRightPanelToggle: { type: 'boolean' },
     showSecuritySchemeType: { type: 'boolean' },
@@ -571,12 +815,14 @@ const ConfigReferenceDocs: NodeType = {
     showObjectSchemaExamples: { type: 'boolean' },
     disableTryItRequestUrlEncoding: { type: 'boolean' },
     sidebarLinks: 'ConfigSidebarLinks',
-    sideNavStyle: { type: 'string' },
+    sideNavStyle: { enum: ['summary-only', 'path-first', 'id-only'] },
     simpleOneOfTypeLabel: { type: 'boolean' },
     sortEnumValuesAlphabetically: { type: 'boolean' },
     sortOperationsAlphabetically: { type: 'boolean' },
     sortPropsAlphabetically: { type: 'boolean' },
     sortTagsAlphabetically: { type: 'boolean' },
+    suppressWarnings: { type: 'boolean' }, // deprecated
+    unstable_externalDescription: { type: 'boolean' }, // deprecated
     unstable_ignoreMimeParameters: { type: 'boolean' },
     untrustedDefinition: { type: 'boolean' },
   },
@@ -591,6 +837,7 @@ const ConfigMockServer: NodeType = {
 };
 
 export const ConfigTypes: Record<string, NodeType> = {
+  Assert,
   ConfigRoot,
   ConfigApis,
   ConfigApisProperties,
@@ -602,7 +849,9 @@ export const ConfigTypes: Record<string, NodeType> = {
   ConfigLanguage,
   ConfigLabels,
   ConfigSidebarLinks,
+  CommonConfigSidebarLinks,
   ConfigTheme,
+  Context,
   ThemeColors,
   CommonThemeColors,
   BorderThemeColors,
@@ -633,9 +882,12 @@ export const ConfigTypes: Record<string, NodeType> = {
   TokenProps,
   CodeBlock,
   Logo,
+  Fab,
   ButtonOverrides,
   Overrides,
+  ObjectRule,
   RightPanel,
+  Rules,
   Shape,
   ThemeSpacing,
   GenerateCodeSamples,
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 9f3653b6..62b74e78 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -5,7 +5,7 @@ import * as pluralize from 'pluralize';
 import { parseYaml } from './js-yaml';
 import { UserContext } from './walk';
 import type { HttpResolveConfig } from './config';
-import { env } from "./config";
+import { env } from './config';
 
 export { parseYaml, stringifyYaml } from './js-yaml';
 
@@ -155,14 +155,13 @@ export function isPathParameter(pathSegment: string) {
   return pathSegment.startsWith('{') && pathSegment.endsWith('}');
 }
 
-
 /**
  * Convert Windows backslash paths to slash paths: foo\\bar ➔ foo/bar
  */
- export function slash(path: string): string {
-  const isExtendedLengthPath = /^\\\\\?\\/.test(path)
+export function slash(path: string): string {
+  const isExtendedLengthPath = /^\\\\\?\\/.test(path);
   if (isExtendedLengthPath) {
-    return path
+    return path;
   }
 
   return path.replace(/\\/g, '/');
@@ -191,3 +190,7 @@ export function assignExisting<T>(target: Record<string, T>, obj: Record<string,
 
 export const getMatchingStatusCodeRange = (code: number | string): string =>
   `${code}`.replace(/^(\d)\d\d$/, (_, firstDigit) => `${firstDigit}XX`);
+
+export function isCustomRuleId(id: string) {
+  return id.includes('/');
+}


Test patch:
diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts
index 716ba4da..7da52acb 100644
--- a/packages/core/src/__tests__/lint.test.ts
+++ b/packages/core/src/__tests__/lint.test.ts
@@ -97,7 +97,7 @@ describe('lint', () => {
               "source": "",
             },
           ],
-          "message": "Expected type \`string\` but got \`object\`.",
+          "message": "\`layout\` can be one of the following only: \\"stacked\\", \\"three-panel\\".",
           "ruleId": "spec",
           "severity": "error",
           "suggest": Array [],
@@ -123,6 +123,19 @@ describe('lint', () => {
 
     expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
       Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/apis/lint",
+              "reportOnKey": true,
+              "source": "",
+            },
+          ],
+          "message": "The field \`root\` must be present on this level.",
+          "ruleId": "spec",
+          "severity": "error",
+          "suggest": Array [],
+        },
         Object {
           "location": Array [
             Object {