feat: cache instantiated plugins and resolve realm-specific properties (#1716)

This commit is contained in:
volodymyr-rutskyi
2024-09-11 09:48:13 +03:00
committed by GitHub
parent e046f7b1de
commit 92652e2346
10 changed files with 158 additions and 30 deletions

View File

@@ -0,0 +1,5 @@
---
"@redocly/openapi-core": minor
---
Added a mechanism that resolves plugin properties specific to the Reunite-hosted product family.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
plugins:
- plugin-with-init-logic.js

View File

@@ -0,0 +1,2 @@
plugins:
- realm-plugin.js

View File

@@ -0,0 +1,9 @@
var util = require('util');
module.exports = function pluginWithInitLogic() {
util.deprecate(() => null);
return {
id: 'test-plugin',
};
};

View File

@@ -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'],
};
};

View File

@@ -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)

View File

@@ -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 = {