Repository: Redocly/redocly-cli
Pull request #836 (2022-08-30 12:53:14)

fix: $anchors in openapi documents are not properly parsed
## What/Why/How?

Closes https://github.com/Redocly/redocly-cli/issues/462

In order to not fail when bundling, `$anchor`s need to be resolved correctly. 
I decided to collect all `$anchors` into `anchorRefsMap` while walking a document.

## Reference

[JSON Schema documentation on $anchor](https://json-schema.org/draft/2019-09/json-schema-core.html#anchor)
[More on $anchor](https://json-schema.org/understanding-json-schema/structuring.html#anchor)

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [x] 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 for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 72.34%     |    3120/4313    |
|                                                                           🟡                                                                           | Branches   | 64.2%      |    1684/2623    |
|                                                                           🟡                                                                           | Functions  | 64.1%      |     507/791     |
|                                                                           🟡                                                                           | Lines      | 72.33%     |    2901/4011    |


## Test suite run success
476 tests passing in 81 suites.

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


Patch:
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 2433c521..63233ad3 100755
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -17,6 +17,7 @@ import type {
   ResolvedConfig,
   ResolvedStyleguideConfig,
   RuleConfig,
+  RuleSettings,
 } from './types';
 import { getResolveConfig } from './utils';
 
@@ -183,7 +184,7 @@ export class StyleguideConfig {
     return extendedTypes;
   }
 
-  getRuleSettings(ruleId: string, oasVersion: OasVersion) {
+  getRuleSettings(ruleId: string, oasVersion: OasVersion): RuleSettings {
     this._usedRules.add(ruleId);
     this._usedVersions.add(oasVersion);
     const settings = this.rules[oasVersion][ruleId] || 'off';
@@ -196,7 +197,7 @@ export class StyleguideConfig {
     }
   }
 
-  getPreprocessorSettings(ruleId: string, oasVersion: OasVersion) {
+  getPreprocessorSettings(ruleId: string, oasVersion: OasVersion): RuleSettings {
     this._usedRules.add(ruleId);
     this._usedVersions.add(oasVersion);
 
@@ -210,7 +211,7 @@ export class StyleguideConfig {
     }
   }
 
-  getDecoratorSettings(ruleId: string, oasVersion: OasVersion) {
+  getDecoratorSettings(ruleId: string, oasVersion: OasVersion): RuleSettings {
     this._usedRules.add(ruleId);
     this._usedVersions.add(oasVersion);
     const settings = this.decorators[oasVersion][ruleId] || 'off';
diff --git a/packages/core/src/config/rules.ts b/packages/core/src/config/rules.ts
index 543f4848..3cdbf3d4 100644
--- a/packages/core/src/config/rules.ts
+++ b/packages/core/src/config/rules.ts
@@ -1,13 +1,20 @@
 import { RuleSet, OasVersion } from '../oas-types';
 import { StyleguideConfig } from './config';
 import { isDefined } from '../utils';
+import type { ProblemSeverity } from '../walk';
+
+type InitializedRule = {
+  severity: ProblemSeverity;
+  ruleId: string;
+  visitor: any;
+};
 
 export function initRules<T extends Function, P extends RuleSet<T>>(
   rules: P[],
   config: StyleguideConfig,
   type: 'rules' | 'preprocessors' | 'decorators',
   oasVersion: OasVersion
-) {
+): InitializedRule[] {
   return rules
     .flatMap((ruleset) =>
       Object.keys(ruleset).map((ruleId) => {
@@ -23,19 +30,20 @@ export function initRules<T extends Function, P extends RuleSet<T>>(
         if (ruleSettings.severity === 'off') {
           return undefined;
         }
+        const severity: ProblemSeverity = ruleSettings.severity;
 
         const visitors = rule(ruleSettings);
 
         if (Array.isArray(visitors)) {
           return visitors.map((visitor: any) => ({
-            severity: ruleSettings.severity,
+            severity,
             ruleId,
             visitor: visitor,
           }));
         }
 
         return {
-          severity: ruleSettings.severity,
+          severity,
           ruleId,
           visitor: visitors, // note: actually it is only one visitor object
         };
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index 3ed93c6e..db957763 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -14,6 +14,8 @@ import { Location } from '../ref-utils';
 
 export type RuleSeverity = ProblemSeverity | 'off';
 
+export type RuleSettings = { severity: RuleSeverity };
+
 export type PreprocessorSeverity = RuleSeverity | 'on';
 
 export type RuleConfig =
diff --git a/packages/core/src/ref-utils.ts b/packages/core/src/ref-utils.ts
index cbc9b1f4..bdd7940a 100644
--- a/packages/core/src/ref-utils.ts
+++ b/packages/core/src/ref-utils.ts
@@ -79,3 +79,7 @@ export function isMappingRef(mapping: string) {
     mapping.indexOf('/') > -1
   );
 }
+
+export function isAnchor(ref: string) {
+  return /^#[A-Za-z][A-Za-z0-9\-_:.]*$/.test(ref);
+}
diff --git a/packages/core/src/resolve.ts b/packages/core/src/resolve.ts
index 9f875428..f9412db9 100644
--- a/packages/core/src/resolve.ts
+++ b/packages/core/src/resolve.ts
@@ -1,10 +1,10 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import { OasRef } from './typings/openapi';
-import { isRef, joinPointer, escapePointer, parseRef, isAbsoluteUrl } from './ref-utils';
+import { isRef, joinPointer, escapePointer, parseRef, isAbsoluteUrl, isAnchor } from './ref-utils';
 import type { YAMLNode, LoadOptions } from 'yaml-ast-parser';
 import { NormalizedNodeType, isNamedType } from './types';
-import { readFileFromUrl, parseYaml } from './utils';
+import { readFileFromUrl, parseYaml, nextTick } from './utils';
 import { ResolveConfig } from './config/types';
 
 export type CollectedRefs = Map<string /* absoluteFilePath */, Document>;
@@ -237,6 +237,7 @@ export async function resolveDocument(opts: {
     type: any
   ) {
     const rootNodeDocAbsoluteRef = rootNodeDocument.source.absoluteRef;
+    const anchorRefsMap: Map<string, any> = new Map();
 
     walk(rootNode, type, rootNodeDocAbsoluteRef + rootNodePointer);
 
@@ -252,6 +253,11 @@ export async function resolveDocument(opts: {
 
       seedNodes.add(nodeId);
 
+      const [_, anchor] = Object.entries(node).find(([key]) => key === '$anchor') || [];
+      if (anchor) {
+        anchorRefsMap.set(`#${anchor}`, node);
+      }
+
       if (Array.isArray(node)) {
         const itemsType = type.items;
         // we continue resolving unknown types, but stop early on known scalars
@@ -313,6 +319,22 @@ export async function resolveDocument(opts: {
       if (hasRef(refStack.prev, ref)) {
         throw new Error('Self-referencing circular pointer');
       }
+
+      if (isAnchor(ref.$ref)) {
+        // Wait for all anchors in the document to be collected firstly.
+        await nextTick();
+        const resolvedRef: ResolvedRef = {
+          resolved: true,
+          isRemote: false,
+          node: anchorRefsMap.get(ref.$ref),
+          document,
+          nodePointer: ref.$ref,
+        };
+        const refId = makeRefId(document.source.absoluteRef, ref.$ref);
+        resolvedRefMap.set(refId, resolvedRef);
+        return resolvedRef;
+      }
+
       const { uri, pointer } = parseRef(ref.$ref);
       const isRemote = uri !== null;
       let targetDoc: Document;
@@ -336,7 +358,7 @@ export async function resolveDocument(opts: {
       }
 
       let resolvedRef: ResolvedRef = {
-        resolved: true as const,
+        resolved: true,
         document: targetDoc,
         isRemote,
         node: document.parsed,
diff --git a/packages/core/src/types/oas3_1.ts b/packages/core/src/types/oas3_1.ts
index 8fd5ff8e..cfa55591 100755
--- a/packages/core/src/types/oas3_1.ts
+++ b/packages/core/src/types/oas3_1.ts
@@ -81,6 +81,7 @@ const Operation: NodeType = {
 const Schema: NodeType = {
   properties: {
     $id: { type: 'string' },
+    $anchor: { type: 'string' },
     id: { type: 'string' },
     $schema: { type: 'string' },
     definitions: 'NamedSchemas',
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 45d69b70..1dd744e8 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -249,3 +249,9 @@ export function pickDefined<T extends Record<string, unknown>>(
   }
   return res;
 }
+
+export function nextTick() {
+  new Promise((resolve) => {
+    setTimeout(resolve);
+  });
+}


Test patch:
diff --git a/__tests__/bundle/bundle-anchor/.redocly.yaml b/__tests__/bundle/bundle-anchor/.redocly.yaml
new file mode 100644
index 00000000..936a3d7d
--- /dev/null
+++ b/__tests__/bundle/bundle-anchor/.redocly.yaml
@@ -0,0 +1,8 @@
+apis:
+  main:
+    root: ./test.yaml
+    extends: []
+    rules:
+      no-invalid-media-type-examples:
+        severity: error
+        allowAdditionalProperties: false
diff --git a/__tests__/bundle/bundle-anchor/snapshot.js b/__tests__/bundle/bundle-anchor/snapshot.js
new file mode 100644
index 00000000..4b864dc0
--- /dev/null
+++ b/__tests__/bundle/bundle-anchor/snapshot.js
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E bundle bundle-anchor 1`] = `
+openapi: 3.1.0
+paths:
+  /test-api:
+    get:
+      responses:
+        '200':
+          description: success
+          content:
+            application/json:
+              schema:
+                $defs:
+                  main_data:
+                    $anchor: main_data
+                    type: object
+                    properties:
+                      foo:
+                        type: string
+                type: object
+                oneOf:
+                  - properties:
+                      wrapper:
+                        $ref: '#main_data'
+                  - $ref: '#main_data'
+              example:
+                foo: TEST
+components: {}
+
+No configurations were defined in extends -- using built in recommended configuration by default.
+Warning! This default behavior is going to be deprecated soon.
+
+Woohoo! Your OpenAPI definitions are valid. 🎉
+
+bundling ./test.yaml...
+📦 Created a bundle for ./test.yaml at stdout <test>ms.
+
+`;
diff --git a/__tests__/bundle/bundle-anchor/test.yaml b/__tests__/bundle/bundle-anchor/test.yaml
new file mode 100644
index 00000000..e0b43a92
--- /dev/null
+++ b/__tests__/bundle/bundle-anchor/test.yaml
@@ -0,0 +1,25 @@
+openapi: 3.1.0
+paths:
+  /test-api:
+    get:
+      responses:
+        '200':
+          description: success
+          content:
+            application/json:
+              schema:
+                $defs:
+                  main_data:
+                    $anchor: main_data
+                    type: object
+                    properties: 
+                      foo:
+                        type: string
+                type: object
+                oneOf:
+                - properties:
+                    wrapper:
+                      $ref: '#main_data'
+                - $ref: '#main_data'
+              example: 
+                foo: TEST
diff --git a/packages/core/src/__tests__/bundle.test.ts b/packages/core/src/__tests__/bundle.test.ts
index 465f6176..181494e9 100644
--- a/packages/core/src/__tests__/bundle.test.ts
+++ b/packages/core/src/__tests__/bundle.test.ts
@@ -187,4 +187,50 @@ describe('bundle', () => {
     expect(problems).toHaveLength(0);
     expect(parsedMeta).toMatchSnapshot();
   });
+
+  it('should bundle refs using $anchors', async () => {
+    const testDocument = parseYamlToDocument(
+      outdent`
+        openapi: 3.1.0
+        components:
+          schemas:
+            User:
+              type: object
+              properties:
+                profile:
+                  $ref: '#user-profile'
+            UserProfile:
+              $anchor: user-profile
+              type: string
+      `,
+      ''
+    );
+
+    const config = await makeConfig({});
+
+    const {
+      bundle: { parsed },
+      problems,
+    } = await bundleDocument({
+      document: testDocument,
+      config: config,
+      externalRefResolver: new BaseResolver(),
+    });
+
+    expect(problems).toHaveLength(0);
+    expect(parsed).toMatchInlineSnapshot(`
+      openapi: 3.1.0
+      components:
+        schemas:
+          User:
+            type: object
+            properties:
+              profile:
+                $ref: '#user-profile'
+          UserProfile:
+            $anchor: user-profile
+            type: string
+
+    `);
+  });
 });