Repository: Redocly/redocly-cli
Pull request #1716 (2024-09-06 12:40:10)

feat: cache instantiated plugins and resolve realm-specific properties
## What/Why/How?

* Added a simple cache for instantiated plugins. This makes sure that the init logic in instantiated plugins is only executed once. This is relevant for Realm.
* Added resolving of properties like `processContent`, `afterRoutesCreated`, and others that are used by Realm. This enables Realm to use `openapi-core` for plugin resolving and thus keep this logic in one place.

## Reference

Internal request: https://github.com/Redocly/redocly/issues/10463

## Testing

## Screenshots (optional)

## Check yourself

- [ ] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [ ] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## 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 | 78.37%     |    4906/6260    |
|                                                                           🟡                                                                           | Branches   | 67.02%     |    2022/3017    |
|                                                                           🟡                                                                           | Functions  | 72.62%     |     809/1114    |
|                                                                           🟡                                                                           | Lines      | 78.67%     |    4628/5883    |


## Test suite run success
793 tests passing in 118 suites.

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


Patch:
diff --git a/.changeset/flat-days-train.md b/.changeset/flat-days-train.md
new file mode 100644
index 00000000..66ba2e64
--- /dev/null
+++ b/.changeset/flat-days-train.md
@@ -0,0 +1,5 @@
+---
+"@redocly/openapi-core": minor
+---
+
+Added a mechanism that resolves plugin properties specific to the Reunite-hosted product family.
diff --git a/.changeset/tame-laws-film.md b/.changeset/tame-laws-film.md
new file mode 100644
index 00000000..b41b75ef
--- /dev/null
+++ b/.changeset/tame-laws-film.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": minor
+"@redocly/cli": minor
+---
+
+Added a cache for resolved plugins to ensure that plugins are only instantiated once during a single execution.
diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts
index 21518511..92628cef 100644
--- a/packages/core/src/config/config-resolvers.ts
+++ b/packages/core/src/config/config-resolvers.ts
@@ -43,6 +43,9 @@ const DEFAULT_PROJECT_PLUGIN_PATHS = ['@theme/plugin.js', '@theme/plugin.cjs', '
 // Workaround for dynamic imports being transpiled to require by Typescript: https://github.com/microsoft/TypeScript/issues/43329#issuecomment-811606238
 const _importDynamic = new Function('modulePath', 'return import(modulePath)');
 
+// Cache instantiated plugins during a single execution
+const pluginsCache: Map<string, Plugin> = new Map();
+
 export async function resolveConfigFileAndRefs({
   configPath,
   externalRefResolver = new BaseResolver(),
@@ -111,9 +114,9 @@ export async function resolveConfig({
   );
 }
 
-function getDefaultPluginPath(configPath: string): string | undefined {
+function getDefaultPluginPath(configDir: string): string | undefined {
   for (const pluginPath of DEFAULT_PROJECT_PLUGIN_PATHS) {
-    const absolutePluginPath = path.resolve(path.dirname(configPath), pluginPath);
+    const absolutePluginPath = path.resolve(configDir, pluginPath);
     if (existsSync(absolutePluginPath)) {
       return pluginPath;
     }
@@ -123,32 +126,58 @@ function getDefaultPluginPath(configPath: string): string | undefined {
 
 export async function resolvePlugins(
   plugins: (string | Plugin)[] | null,
-  configPath: string = ''
+  configDir: string = ''
 ): Promise<Plugin[]> {
   if (!plugins) return [];
 
   // TODO: implement or reuse Resolver approach so it will work in node and browser envs
-  const requireFunc = async (plugin: string | Plugin): Promise<ImportedPlugin | undefined> => {
+  const requireFunc = async (plugin: string | Plugin): Promise<Plugin | undefined> => {
     if (isString(plugin)) {
       try {
-        const maybeAbsolutePluginPath = path.resolve(path.dirname(configPath), plugin);
+        const maybeAbsolutePluginPath = path.resolve(configDir, plugin);
 
         const absolutePluginPath = existsSync(maybeAbsolutePluginPath)
           ? maybeAbsolutePluginPath
           : // For plugins imported from packages specifically
             require.resolve(plugin);
 
-        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-        // @ts-ignore
-        if (typeof __webpack_require__ === 'function') {
+        if (!pluginsCache.has(absolutePluginPath)) {
+          let requiredPlugin: ImportedPlugin | undefined;
+
           // eslint-disable-next-line @typescript-eslint/ban-ts-comment
           // @ts-ignore
-          return __non_webpack_require__(absolutePluginPath);
-        } else {
-          // you can import both cjs and mjs
-          const mod = await _importDynamic(pathToFileURL(absolutePluginPath).href);
-          return mod.default || mod;
+          if (typeof __webpack_require__ === 'function') {
+            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+            // @ts-ignore
+            requiredPlugin = __non_webpack_require__(absolutePluginPath);
+          } else {
+            // you can import both cjs and mjs
+            const mod = await _importDynamic(pathToFileURL(absolutePluginPath).href);
+            requiredPlugin = mod.default || mod;
+          }
+
+          const pluginCreatorOptions = { contentDir: configDir };
+
+          const pluginModule = isDeprecatedPluginFormat(requiredPlugin)
+            ? requiredPlugin
+            : isCommonJsPlugin(requiredPlugin)
+            ? await requiredPlugin(pluginCreatorOptions)
+            : await requiredPlugin?.default?.(pluginCreatorOptions);
+
+          if (pluginModule?.id && isDeprecatedPluginFormat(requiredPlugin)) {
+            logger.info(`Deprecated plugin format detected: ${pluginModule.id}\n`);
+          }
+
+          if (pluginModule) {
+            pluginsCache.set(absolutePluginPath, {
+              ...pluginModule,
+              path: plugin,
+              absolutePath: absolutePluginPath,
+            });
+          }
         }
+
+        return pluginsCache.get(absolutePluginPath);
       } catch (e) {
         throw new Error(`Failed to load plugin "${plugin}": ${e.message}\n\n${e.stack}`);
       }
@@ -162,7 +191,7 @@ export async function resolvePlugins(
   /**
    * Include the default plugin automatically if it's not in configuration
    */
-  const defaultPluginPath = getDefaultPluginPath(configPath);
+  const defaultPluginPath = getDefaultPluginPath(configDir);
   if (defaultPluginPath) {
     plugins.push(defaultPluginPath);
   }
@@ -182,24 +211,12 @@ export async function resolvePlugins(
         resolvedPlugins.add(p);
       }
 
-      const requiredPlugin: ImportedPlugin | undefined = await requireFunc(p);
-
-      const pluginCreatorOptions = { contentDir: path.dirname(configPath) };
-
-      const pluginModule = isDeprecatedPluginFormat(requiredPlugin)
-        ? requiredPlugin
-        : isCommonJsPlugin(requiredPlugin)
-        ? await requiredPlugin(pluginCreatorOptions)
-        : await requiredPlugin?.default?.(pluginCreatorOptions);
+      const pluginModule: Plugin | undefined = await requireFunc(p);
 
       if (!pluginModule) {
         return;
       }
 
-      if (isString(p) && pluginModule.id && isDeprecatedPluginFormat(requiredPlugin)) {
-        logger.info(`Deprecated plugin format detected: ${pluginModule.id}\n`);
-      }
-
       const id = pluginModule.id;
       if (typeof id !== 'string') {
         throw new Error(
@@ -313,7 +330,10 @@ export async function resolvePlugins(
         plugin.assertions = pluginModule.assertions;
       }
 
-      return plugin;
+      return {
+        ...pluginModule,
+        ...plugin,
+      };
     })
   );
 
@@ -371,7 +391,10 @@ async function resolveAndMergeNestedStyleguideConfig(
     ? // In browser, we don't support plugins from config file yet
       [defaultPlugin]
     : getUniquePlugins(
-        await resolvePlugins([...(styleguideConfig?.plugins || []), defaultPlugin], configPath)
+        await resolvePlugins(
+          [...(styleguideConfig?.plugins || []), defaultPlugin],
+          path.dirname(configPath)
+        )
       );
   const pluginPaths = styleguideConfig?.plugins
     ?.filter(isString)
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index 67c20f82..9f15af65 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -30,6 +30,7 @@ import type {
   BuiltInOAS3RuleId,
   BuiltInArazzoRuleId,
 } from '../types/redocly-yaml';
+import type { JSONSchema } from 'json-schema-to-ts';
 
 export type RuleSeverity = ProblemSeverity | 'off';
 
@@ -136,12 +137,27 @@ export type AssertionsConfig = Record<string, CustomFunction>;
 
 export type Plugin = {
   id: string;
+
   configs?: Record<string, PluginStyleguideConfig>;
   rules?: CustomRulesConfig;
   preprocessors?: PreprocessorsConfig;
   decorators?: DecoratorsConfig;
   typeExtension?: TypeExtensionsConfig;
   assertions?: AssertionsConfig;
+
+  // Realm properties
+  path?: string;
+  absolutePath?: string;
+  processContent?: (actions: any, content: any) => Promise<void> | void;
+  afterRoutesCreated?: (actions: any, content: any) => Promise<void> | void;
+  loaders?: Record<
+    string,
+    (path: string, context: any, reportError: (error: Error) => void) => Promise<unknown>
+  >;
+  requiredEntitlements?: string[];
+  ssoConfigSchema?: JSONSchema;
+  redoclyConfigSchema?: JSONSchema;
+  ejectIgnore?: string[];
 };
 
 type PluginCreatorOptions = {


Test patch:
diff --git a/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js b/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js
index 5708e77a..6a1450be 100644
--- a/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js
+++ b/__tests__/bundle/resolve-refs-from-x-attributes/snapshot.js
@@ -18,7 +18,6 @@ info:
         name: test 2
 components: {}
 
-Deprecated plugin format detected: plugin
 Deprecated plugin format detected: plugin
 bundling ./openapi.yaml...
 📦 Created a bundle for ./openapi.yaml at stdout <test>ms.
diff --git a/packages/core/src/config/__tests__/config-resolvers.test.ts b/packages/core/src/config/__tests__/config-resolvers.test.ts
index 2b4bdd78..9a33df83 100644
--- a/packages/core/src/config/__tests__/config-resolvers.test.ts
+++ b/packages/core/src/config/__tests__/config-resolvers.test.ts
@@ -1,3 +1,4 @@
+import * as util from 'util';
 import { colorize } from '../../logger';
 import { Asserts, asserts } from '../../rules/common/assertions/asserts';
 import { resolveStyleguideConfig, resolveApis, resolveConfig } from '../config-resolvers';
@@ -91,6 +92,59 @@ describe('resolveStyleguideConfig', () => {
     });
   });
 
+  it('should instantiate the plugin once', async () => {
+    // Called by plugin during init
+    const deprecateSpy = jest.spyOn(util, 'deprecate');
+
+    const config = {
+      ...baseStyleguideConfig,
+      extends: ['local-config-with-plugin-init.yaml'],
+    };
+
+    await resolveStyleguideConfig({
+      styleguideConfig: config,
+      configPath,
+    });
+
+    expect(deprecateSpy).toHaveBeenCalledTimes(1);
+
+    await resolveStyleguideConfig({
+      styleguideConfig: config,
+      configPath,
+    });
+
+    // Should not execute the init logic again
+    expect(deprecateSpy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should resolve realm plugin properties', async () => {
+    const config = {
+      ...baseStyleguideConfig,
+      extends: ['local-config-with-realm-plugin.yaml'],
+    };
+
+    const { plugins } = await resolveStyleguideConfig({
+      styleguideConfig: config,
+      configPath,
+    });
+
+    const localPlugin = plugins?.find((p) => p.id === 'realm-plugin');
+    expect(localPlugin).toBeDefined();
+
+    expect(localPlugin).toMatchObject({
+      id: 'realm-plugin',
+      processContent: expect.any(Function),
+      afterRoutesCreated: expect.any(Function),
+      loaders: {
+        'test-loader': expect.any(Function),
+      },
+      requiredEntitlements: ['test-entitlement'],
+      ssoConfigSchema: { type: 'object', additionalProperties: true },
+      redoclyConfigSchema: { type: 'object', additionalProperties: false },
+      ejectIgnore: ['Navbar.tsx', 'Footer.tsx'],
+    });
+  });
+
   it('should resolve local file config with esm plugin', async () => {
     const config = {
       ...baseStyleguideConfig,
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-plugin-init.yaml b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-plugin-init.yaml
new file mode 100644
index 00000000..49efe0ad
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-plugin-init.yaml
@@ -0,0 +1,2 @@
+plugins:
+  - plugin-with-init-logic.js
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-realm-plugin.yaml b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-realm-plugin.yaml
new file mode 100644
index 00000000..61ce47db
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/local-config-with-realm-plugin.yaml
@@ -0,0 +1,2 @@
+plugins:
+  - realm-plugin.js
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-with-init-logic.js b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-with-init-logic.js
new file mode 100644
index 00000000..d83f5c1d
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/plugin-with-init-logic.js
@@ -0,0 +1,9 @@
+var util = require('util');
+
+module.exports = function pluginWithInitLogic() {
+  util.deprecate(() => null);
+
+  return {
+    id: 'test-plugin',
+  };
+};
diff --git a/packages/core/src/config/__tests__/fixtures/resolve-config/realm-plugin.js b/packages/core/src/config/__tests__/fixtures/resolve-config/realm-plugin.js
new file mode 100644
index 00000000..dc5fb55b
--- /dev/null
+++ b/packages/core/src/config/__tests__/fixtures/resolve-config/realm-plugin.js
@@ -0,0 +1,12 @@
+module.exports = function realmPlugin() {
+  return {
+    id: 'realm-plugin',
+    processContent: () => {},
+    afterRoutesCreated: () => {},
+    loaders: { 'test-loader': () => {} },
+    requiredEntitlements: ['test-entitlement'],
+    ssoConfigSchema: { type: 'object', additionalProperties: true },
+    redoclyConfigSchema: { type: 'object', additionalProperties: false },
+    ejectIgnore: ['Navbar.tsx', 'Footer.tsx'],
+  };
+};