feat: add support for a plugin interface common with Realm (#1661)

This commit is contained in:
volodymyr-rutskyi
2024-08-21 14:23:02 +03:00
committed by GitHub
parent 9ce88a33a5
commit 7a0e52f57e
46 changed files with 637 additions and 272 deletions

View File

@@ -0,0 +1,6 @@
---
"@redocly/openapi-core": minor
"@redocly/cli": minor
---
Added support for ESM plugins and importing of plugins directly from npm package: `@vendor/package/plugin.js` instead of `./node_modules/@vendor/package/plugin.js`.

View File

@@ -0,0 +1,6 @@
---
"@redocly/openapi-core": minor
"@redocly/cli": minor
---
Changed plugins format to export a function instead of an object for compatibility with other Redocly products. The backwards compatibility with an old format of plugins is maintained but will be removed in the future.

View File

@@ -18,6 +18,8 @@ info:
name: test 2 name: test 2
components: {} components: {}
Deprecated plugin format detected: plugin
Deprecated plugin format detected: plugin
bundling ./openapi.yaml... bundling ./openapi.yaml...
📦 Created a bundle for ./openapi.yaml at stdout <test>ms. 📦 Created a bundle for ./openapi.yaml at stdout <test>ms.

View File

@@ -6,22 +6,24 @@ const XMetaData = {
required: ['lifecycle'], required: ['lifecycle'],
}; };
module.exports = { module.exports = function typeExtensionPlugin() {
id: 'type-extension', return {
typeExtension: { id: 'type-extension',
oas3(types) { typeExtension: {
newTypes = { oas3(types) {
...types, newTypes = {
XMetaData: XMetaData, ...types,
Info: { XMetaData: XMetaData,
...types.Info, Info: {
properties: { ...types.Info,
...types.Info.properties, properties: {
'x-metadata': 'XMetaData', ...types.Info.properties,
'x-metadata': 'XMetaData',
},
}, },
}, };
}; return newTypes;
return newTypes; },
}, },
}, };
}; };

View File

@@ -2,6 +2,7 @@
exports[`E2E check-config wrong config type extension in assertions 1`] = ` exports[`E2E check-config wrong config type extension in assertions 1`] = `
Deprecated plugin format detected: type-extension
[1] redocly.yaml:10:13 at #/rules/rule~1metadata-lifecycle/subject/type [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", "PatternProperties", "NamedPathItems", "DependentRequired", "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", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "NoneSourceDescription", "ArazzoSourceDescription", "Parameters", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExpectSchema", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "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", "XMetaData", "PatternProperties", "NamedPathItems", "DependentRequired", "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", "MessageBindings", "OperationBindings", "ServerMap", "ChannelMap", "Channel", "ParametersMap", "MessageExample", "NamedMessages", "NamedMessageTraits", "NamedOperationTraits", "NamedCorrelationIds", "SecuritySchemeFlows", "Message", "OperationTrait", "OperationTraitList", "MessageTrait", "MessageTraitList", "MessageExampleList", "CorrelationId", "Dependencies", "OperationReply", "OperationReplyAddress", "NamedTags", "NamedExternalDocs", "NamedChannels", "NamedOperations", "NamedOperationReplies", "NamedOperationRelyAddresses", "SecuritySchemeList", "MessageList", "SourceDescriptions", "OpenAPISourceDescription", "NoneSourceDescription", "ArazzoSourceDescription", "Parameters", "ReusableObject", "Workflows", "Workflow", "Steps", "Step", "Replacement", "ExtendedOperation", "ExpectSchema", "Outputs", "CriterionObject", "XPathCriterion", "JSONPathCriterion", "SuccessActionObject", "OnSuccessActionList", "FailureActionObject", "OnFailureActionList", "NamedInputs", "NamedSuccessActions", "NamedFailureActions", "SpecExtension".

View File

@@ -558,6 +558,26 @@ describe('E2E', () => {
const result = getCommandOutput(args, testPath); const result = getCommandOutput(args, testPath);
(<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js')); (<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
}); });
test('lint with a rule from a plugin', () => {
const testPath = join(folderPath, 'resolve-plugins');
const args = getParams('../../../packages/cli/src/index.ts', 'lint', [
'openapi.yaml',
'--config=plugin-config.yaml',
]);
const result = getCommandOutput(args, testPath);
(<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
});
test('decorate with a decorator from a plugin', () => {
const testPath = join(folderPath, 'resolve-plugins');
const args = getParams('../../../packages/cli/src/index.ts', 'bundle', [
'openapi.yaml',
'--config=plugin-config.yaml',
]);
const result = getCommandOutput(args, testPath);
(<any>expect(cleanupOutput(result))).toMatchSpecificSnapshot(join(testPath, 'snapshot.js'));
});
}); });
describe('build-docs', () => { describe('build-docs', () => {

View File

@@ -2,6 +2,7 @@
exports[`E2E lint arazzo-type-extensions-with-plugin 1`] = ` exports[`E2E lint arazzo-type-extensions-with-plugin 1`] = `
Deprecated plugin format detected: type-extension
validating museum.yaml... validating museum.yaml...
[1] museum.yaml:8:3 at #/info/wrong-key [1] museum.yaml:8:3 at #/info/wrong-key

View File

@@ -1,28 +0,0 @@
module.exports = {
id: 'local',
assertions: {
checkSchema: (_, opts, ctx) => {
const name = opts.required;
const rawValue = ctx.rawValue;
const errors = [];
if (Array.isArray(rawValue?.required) && rawValue?.properties) {
const required = rawValue?.required;
const properties = rawValue?.properties;
for (const item of required) {
if (properties[item] && properties[item].type === 'string') {
if (!properties[item][name]) {
errors.push({
message: `Required property ${name} inside ${item} string property`,
location: ctx.baseLocation,
});
}
}
}
}
return errors;
},
},
};

View File

@@ -0,0 +1,30 @@
export default function plugin() {
return {
id: 'local',
assertions: {
checkSchema: (_, opts, ctx) => {
const name = opts.required;
const rawValue = ctx.rawValue;
const errors = [];
if (Array.isArray(rawValue?.required) && rawValue?.properties) {
const required = rawValue?.required;
const properties = rawValue?.properties;
for (const item of required) {
if (properties[item] && properties[item].type === 'string') {
if (!properties[item][name]) {
errors.push({
message: `Required property ${name} inside ${item} string property`,
location: ctx.baseLocation,
});
}
}
}
}
return errors;
},
},
};
}

View File

@@ -3,7 +3,7 @@ apis:
root: ./openapi.yaml root: ./openapi.yaml
plugins: plugins:
- ./plugin.js - ./plugin.mjs
rules: rules:
rule/minLength: rule/minLength:
subject: subject:

View File

@@ -1,29 +0,0 @@
module.exports = {
id: 'local',
assertions: {
checkWordsStarts: (value, opts, ctx) => {
const regexp = new RegExp(`^${opts.words.join('|')}`);
if (regexp.test(value)) {
return [];
}
return [
{
message: `Should start with one of ${opts.words.join(', ')}`,
location: ctx.baseLocation,
},
];
},
checkWordsCount: (value, opts, ctx) => {
const words = value.split(' ');
if (words.length >= opts.min) {
return [];
}
return [
{
message: `Should have at least ${opts.min} words`,
location: ctx.baseLocation,
},
];
},
},
};

View File

@@ -0,0 +1,31 @@
export default async function plugin() {
return {
id: 'local',
assertions: {
checkWordsStarts: (value, opts, ctx) => {
const regexp = new RegExp(`^${opts.words.join('|')}`);
if (regexp.test(value)) {
return [];
}
return [
{
message: `Should start with one of ${opts.words.join(', ')}`,
location: ctx.baseLocation,
},
];
},
checkWordsCount: (value, opts, ctx) => {
const words = value.split(' ');
if (words.length >= opts.min) {
return [];
}
return [
{
message: `Should have at least ${opts.min} words`,
location: ctx.baseLocation,
},
];
},
},
};
}

View File

@@ -2,7 +2,7 @@ apis:
main: main:
root: ./openapi.yaml root: ./openapi.yaml
plugins: plugins:
- ./plugin.js - ./plugin.mjs
rules: rules:
rule/operation-summary-length-custom: rule/operation-summary-length-custom:
subject: subject:

View File

@@ -0,0 +1,9 @@
openapi: 3.1.0
info:
title: Test
paths: {}
components:
securitySchemes:
OpenID:
type: openIdConnect
openIdConnectUrl: https://example.com/missing-well-known-configuration

View File

@@ -0,0 +1,6 @@
plugins:
- '../../../packages/core/src/config/__tests__/fixtures/plugin.js'
decorators:
test-plugin/inject-x-stats: on
rules:
test-plugin/openid-connect-url-well-known: error

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`E2E miscellaneous decorate with a decorator from a plugin 1`] = `
openapi: 3.1.0
info:
title: Test
x-stats:
test: 1
paths: {}
components:
securitySchemes:
OpenID:
type: openIdConnect
openIdConnectUrl: https://example.com/missing-well-known-configuration
Deprecated plugin format detected: test-plugin
bundling openapi.yaml...
📦 Created a bundle for openapi.yaml at stdout <test>ms.
`;
exports[`E2E miscellaneous lint with a rule from a plugin 1`] = `
Deprecated plugin format detected: test-plugin
validating openapi.yaml...
[1] openapi.yaml:9:25 at #/components/securitySchemes/OpenID/openIdConnectUrl
openIdConnectUrl must be a URL that ends with /.well-known/openid-configuration
7 | OpenID:
8 | type: openIdConnect
9 | openIdConnectUrl: https://example.com/missing-well-known-configuration
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
10 |
Error was generated by the test-plugin/openid-connect-url-well-known rule.
openapi.yaml: validated in <test>ms
❌ Validation failed with 1 error.
run \`redocly lint --generate-ignore-file\` to add all problems to the ignore file.
`;

View File

@@ -17,7 +17,9 @@ const preprocessors = {
}, },
}; };
module.exports = { module.exports = function plugin() {
id: 'plugin', return {
preprocessors, id: 'plugin',
preprocessors,
};
}; };

View File

@@ -9,22 +9,24 @@ configuration file.
The following is an example plugin, defining two configuration bundles: The following is an example plugin, defining two configuration bundles:
```js ```js
module.exports = { module.exports = function myLocalPlugin() {
id: 'my-local-plugin' return {
configs: { id: 'my-local-plugin',
all: { configs: {
rules: { all: {
'operation-id-not-test': 'error', rules: {
'boolean-parameter-prefixes': 'error', 'operation-id-not-test': 'error',
'boolean-parameter-prefixes': 'error',
},
},
minimal: {
rules: {
'operation-id-not-test': 'off',
'boolean-parameter-prefixes': 'error',
},
}, },
}, },
minimal: { };
rules: {
'operation-id-not-test': 'off',
'boolean-parameter-prefixes': 'error',
},
}
}
}; };
``` ```

View File

@@ -13,26 +13,29 @@ Decorators and preprocessors are the same in structure, but preprocessors are ru
## Plugin structure ## Plugin structure
To create a preprocessor or decorator, the object that is exported from your module has to conform to an interface such as the following example: To create a preprocessor or decorator, the function that is exported from your module has to conform to an interface such as the following example:
```js ```js
module.exports = { module.exports = function myLocalPlugin() {
id: 'my-local-plugin', return {
preprocessors: { id: 'my-local-plugin',
oas3: { preprocessors: {
"processor-id": () => { oas3: {
// ... 'processor-id': () => {
} // ...
} },
}, },
decorators: { },
oas3: { decorators: {
"decorator-id": () => { oas3: {
// ... 'decorator-id': () => {
} // ...
} },
} },
} },
};
};
``` ```
Each decorator or preprocessor is a function that returns an object. The object's keys are the node types in the document, and each of those can contain any or all of the `enter()`, `leave()` and `skip()` functions for that node type. Find more information and examples on the [visitor pattern page](./visitor.md). Each decorator or preprocessor is a function that returns an object. The object's keys are the node types in the document, and each of those can contain any or all of the `enter()`, `leave()` and `skip()` functions for that node type. Find more information and examples on the [visitor pattern page](./visitor.md).
@@ -67,14 +70,16 @@ To use this decorator, add it to a plugin. In this example the main decorator fi
```js ```js
const OperationSparkle = require('./decorators/operation-sparkle.js'); const OperationSparkle = require('./decorators/operation-sparkle.js');
module.exports = { module.exports = function sparklePlugin() {
id: 'sparkle', return {
decorators: { id: "sparkle",
oas3: { decorators: {
'operation-sparkle': OperationSparkle, oas3: {
} "operation-sparkle": OperationSparkle,
} },
}; },
};
}
``` ```
The plugin is good to go. For a user to include it in their Redocly configuration, edit the configuration file to look something like this: The plugin is good to go. For a user to include it in their Redocly configuration, edit the configuration file to look something like this:
@@ -118,15 +123,17 @@ Now extend the decorator from the previous example to add this to the existing p
const OperationSparkle = require('./decorators/operation-sparkle.js'); const OperationSparkle = require('./decorators/operation-sparkle.js');
const OpIdSuffix = require('./decorators/add-suffix.js'); const OpIdSuffix = require('./decorators/add-suffix.js');
module.exports = { module.exports = function sparklePlugin() {
id: 'sparkle', return {
decorators: { id: "sparkle",
oas3: { decorators: {
'operation-sparkle': OperationSparkle, oas3: {
'add-opid-suffix': OpIdSuffix, "operation-sparkle": OperationSparkle,
} "add-opid-suffix": OpIdSuffix,
} },
}; },
};
}
``` ```
All that remains is for a user to configure this decorator in their `redocly.yaml` configuration file to take advantage of the new decorator functionality. Here's an example of the configuration file: All that remains is for a user to configure this decorator in their `redocly.yaml` configuration file to take advantage of the new decorator functionality. Here's an example of the configuration file:

View File

@@ -35,18 +35,21 @@ function OperationIdNotTest() {
The `ctx` object here holds all the context, which can be used to give more situation-aware functionality to the rules you build. This is one of the main use cases for custom rules. The `report()` method is used to give information to return to the user if the node being visited doesn't comply with the rule. You can read the [context](#the-context-object) and [location](#location-object) sections for more information. The `ctx` object here holds all the context, which can be used to give more situation-aware functionality to the rules you build. This is one of the main use cases for custom rules. The `report()` method is used to give information to return to the user if the node being visited doesn't comply with the rule. You can read the [context](#the-context-object) and [location](#location-object) sections for more information.
Adding this as part of a plugin requires you to add it to the `rules` part of the plugin object, under the relevant document type. The example rule here is intended to be used with OpenAPI, so the plugin code in `plugins/my-rules.js` is as follows: Adding this as part of a plugin requires you to add it to the `rules` part of the plugin object returned by the exported function, under the relevant document type.
The example rule here is intended to be used with OpenAPI, so the plugin code in `plugins/my-rules.js` is as follows:
```js ```js
const OperationIdNotTest = require('./rules/opid-not-test.js'); const OperationIdNotTest = require('./rules/opid-not-test.js');
module.exports = { module.exports = function myRulesPlugin() {
id: 'my-rules', return {
rules: { id: 'my-rules',
oas3: { rules: {
'opid-not-test': OperationIdNotTest, oas3: {
} 'opid-not-test': OperationIdNotTest,
}, },
},
};
}; };
``` ```

View File

@@ -38,23 +38,25 @@ Note the quotes around the `owner-team` key since it contains a hyphen `-`. Thes
To include the new type in the type tree, the plugin must add the type and modify the parent type, which in this example is `info`. This is done by returning a `typeExtension` structure, as shown in the example below (this example is in `plugins/example-type-extension.js`, this filename is used again in the configuration example later): To include the new type in the type tree, the plugin must add the type and modify the parent type, which in this example is `info`. This is done by returning a `typeExtension` structure, as shown in the example below (this example is in `plugins/example-type-extension.js`, this filename is used again in the configuration example later):
```js ```js
module.exports = { module.exports = function typeExtensionsPlugin() {
id: 'example-type-extension', return {
typeExtension: { id: 'example-type-extension',
oas3(types) { typeExtension: {
return { oas3(types) {
...types, return {
XMetaData: XMetaData, ...types,
Info: { XMetaData: XMetaData,
...types.Info, Info: {
properties: { ...types.Info,
...types.Info.properties, properties: {
'x-metadata': 'XMetaData', ...types.Info.properties,
} 'x-metadata': 'XMetaData',
} },
}; },
} };
} },
},
};
}; };
``` ```

View File

@@ -43,11 +43,13 @@ The paths are relative to the configuration file location. Where there are multi
### Plugin structure ### Plugin structure
The minimal plugin should export an `id` string that is used to refer to the contents of the plugin in the `redocly.yaml` configuration file: The minimal plugin should export a function that returns an object with a single property `id` that is used to refer to the contents of the plugin in the `redocly.yaml` configuration file:
```js ```js
module.exports = { module.exports = function myPlugin() {
id: 'my-local-plugin', return {
id: 'my-local-plugin',
};
}; };
``` ```

View File

@@ -34,9 +34,11 @@ Estimated time: 15 minutes
}, },
}; };
module.exports = { module.exports = function changeTokenPlugin() {
id, return {
decorators, id,
decorators,
};
}; };
``` ```

View File

@@ -44,9 +44,11 @@ In this step, create a custom plugin and define the decorator dependency.
}, },
}; };
module.exports = { module.exports = function hideExtensionsPlugin() {
id, return {
decorators, id,
decorators,
};
}; };
``` ```

View File

@@ -84,9 +84,11 @@ const decorators = {
}, },
}; };
module.exports = { module.exports = function replaceServersUrlPlugin() {
id, return {
decorators, id,
decorators,
};
}; };
``` ```

View File

@@ -242,34 +242,36 @@ rule/operation-summary-check:
`plugin.js` `plugin.js`
```js ```js
module.exports = { module.exports = function localPlugin() {
id: 'local', return {
assertions: { id: 'local',
checkWordsStarts: (value, options, ctx) => { assertions: {
const regexp = new RegExp(`^${options.words.join('|')}`); checkWordsStarts: (value, options, ctx) => {
if (regexp.test(value)) { const regexp = new RegExp(`^${options.words.join('|')}`);
return []; if (regexp.test(value)) {
} return [];
return [ }
{ return [
message: 'Operation summary should start with an active verb', {
location: ctx.baseLocation, message: 'Operation summary should start with an active verb',
}, location: ctx.baseLocation,
]; },
];
},
checkWordsCount: (value, options, ctx) => {
const words = value.split(' ');
if (words.length >= options.min) {
return [];
}
return [
{
message: `Operation summary should contain at least ${options.min} words`,
location: ctx.baseLocation,
},
];
},
}, },
checkWordsCount: (value, options, ctx) => { };
const words = value.split(' ');
if (words.length >= options.min) {
return [];
}
return [
{
message: `Operation summary should contain at least ${options.min} words`,
location: ctx.baseLocation,
},
];
},
},
}; };
``` ```

View File

@@ -10,13 +10,13 @@
"engineStrict": true, "engineStrict": true,
"scripts": { "scripts": {
"test": "npm run typecheck && npm run compile && npm run unit && npm run e2e", "test": "npm run typecheck && npm run compile && npm run unit && npm run e2e",
"jest": "REDOCLY_TELEMETRY=off jest ./packages", "jest": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" REDOCLY_TELEMETRY=off jest ./packages",
"unit": "npm run jest -- --coverage --coverageReporters lcov text-summary", "unit": "npm run jest -- --coverage --coverageReporters lcov text-summary",
"unit:watch": "REDOCLY_TELEMETRY=off jest --watch", "unit:watch": "REDOCLY_TELEMETRY=off jest --watch",
"coverage:cli": "npm run jest -- --roots packages/cli/src --coverage", "coverage:cli": "npm run jest -- --roots packages/cli/src --coverage",
"coverage:core": "npm run jest -- --roots packages/core/src --coverage", "coverage:core": "npm run jest -- --roots packages/core/src --coverage",
"typecheck": "tsc --noEmit --skipLibCheck", "typecheck": "tsc --noEmit --skipLibCheck",
"e2e": "npm run webpack-bundle -- --mode=none && REDOCLY_TELEMETRY=off jest --roots=./__tests__/", "e2e": "npm run webpack-bundle -- --mode=none && REDOCLY_TELEMETRY=off NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --roots=./__tests__/",
"prettier": " npx prettier --write \"**/*.{ts,js,yaml,yml,json,md}\"", "prettier": " npx prettier --write \"**/*.{ts,js,yaml,yml,json,md}\"",
"prettier:check": "npx prettier --check \"**/*.{ts,js,yaml,yml,json,md}\"", "prettier:check": "npx prettier --check \"**/*.{ts,js,yaml,yml,json,md}\"",
"eslint": "eslint packages/**", "eslint": "eslint packages/**",

View File

@@ -45,7 +45,7 @@ export const yamlSerializer = {
}, },
}; };
export function makeConfigForRuleset( export async function makeConfigForRuleset(
rules: Oas3RuleSet, rules: Oas3RuleSet,
plugin?: Partial<Plugin>, plugin?: Partial<Plugin>,
version: string = 'oas3' version: string = 'oas3'
@@ -55,7 +55,7 @@ export function makeConfigForRuleset(
Object.keys(rules).forEach((name) => { Object.keys(rules).forEach((name) => {
rulesConf[`${ruleId}/${name}`] = 'error'; rulesConf[`${ruleId}/${name}`] = 'error';
}); });
const plugins = resolvePlugins([ const plugins = await resolvePlugins([
{ {
...plugin, ...plugin,
id: ruleId, id: ruleId,

View File

@@ -53,7 +53,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(testRuleSet.test).toBeCalledTimes(1); expect(testRuleSet.test).toBeCalledTimes(1);
@@ -100,12 +100,12 @@ describe('walk order', () => {
const document = parseYamlToDocument( const document = parseYamlToDocument(
outdent` outdent`
openapi: 3.0.0 openapi: 3.0.0
servers: servers:
- url: http://{test}.url - url: http://{test}.url
variables: variables:
test: test test: test
paths: paths:
/test-path: /test-path:
get: get:
responses: responses:
200: 200:
@@ -121,7 +121,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(testRuleSet.test).toBeCalledTimes(1); expect(testRuleSet.test).toBeCalledTimes(1);
@@ -184,7 +184,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -264,7 +264,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet, undefined, 'oas2'), config: await makeConfigForRuleset(testRuleSet, undefined, 'oas2'),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -345,7 +345,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -417,7 +417,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -489,7 +489,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -556,7 +556,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -610,7 +610,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -666,7 +666,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -708,7 +708,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -763,7 +763,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -849,7 +849,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -928,7 +928,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -1000,7 +1000,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -1044,7 +1044,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -1109,7 +1109,7 @@ describe('walk order', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`
@@ -1197,7 +1197,7 @@ describe('context.report', () => {
const results = await lintDocument({ const results = await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(results).toHaveLength(3); expect(results).toHaveLength(3);
@@ -1277,7 +1277,7 @@ describe('context.report', () => {
const results = await lintDocument({ const results = await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(results).toHaveLength(4); expect(results).toHaveLength(4);
@@ -1385,7 +1385,7 @@ describe('context.resolve', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
}); });
}); });
@@ -1434,7 +1434,7 @@ describe('type extensions', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet, { config: await makeConfigForRuleset(testRuleSet, {
typeExtension: { typeExtension: {
oas3(types, version) { oas3(types, version) {
expect(version).toEqual(oas); expect(version).toEqual(oas);
@@ -1528,7 +1528,7 @@ describe('ignoreNextRules', () => {
await lintDocument({ await lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),
document, document,
config: makeConfigForRuleset(testRuleSet), config: await makeConfigForRuleset(testRuleSet),
}); });
expect(calls).toMatchInlineSnapshot(` expect(calls).toMatchInlineSnapshot(`

View File

@@ -4,6 +4,8 @@ import { lintDocument } from '../../lint';
import { BaseResolver } from '../../resolve'; import { BaseResolver } from '../../resolve';
import { parseYamlToDocument, makeConfigForRuleset } from '../utils'; import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
import type { StyleguideConfig } from '../../config';
export const name = 'Validate with 50 top-level rules'; export const name = 'Validate with 50 top-level rules';
export const count = 10; export const count = 10;
const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml')); const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -25,7 +27,11 @@ for (let i = 0; i < 50; i++) {
}; };
} }
const config = makeConfigForRuleset(ruleset); let config: StyleguideConfig;
export async function setupAsync() {
config = await makeConfigForRuleset(ruleset);
}
export function measureAsync() { export function measureAsync() {
return lintDocument({ return lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),

View File

@@ -4,6 +4,8 @@ import { lintDocument } from '../../lint';
import { BaseResolver } from '../../resolve'; import { BaseResolver } from '../../resolve';
import { parseYamlToDocument, makeConfigForRuleset } from '../utils'; import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
import type { StyleguideConfig } from '../../config';
export const name = 'Validate with single nested rule'; export const name = 'Validate with single nested rule';
export const count = 10; export const count = 10;
const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml')); const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -29,7 +31,11 @@ const visitor = {
}; };
}, },
}; };
const config = makeConfigForRuleset(visitor); let config: StyleguideConfig;
export async function setupAsync() {
config = await makeConfigForRuleset(visitor);
}
export function measureAsync() { export function measureAsync() {
return lintDocument({ return lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),

View File

@@ -3,6 +3,9 @@ import { join as pathJoin, resolve as pathResolve } from 'path';
import { lintDocument } from '../../lint'; import { lintDocument } from '../../lint';
import { BaseResolver } from '../../resolve'; import { BaseResolver } from '../../resolve';
import { parseYamlToDocument, makeConfigForRuleset } from '../utils'; import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
import type { StyleguideConfig } from '../../config';
export const name = 'Validate with no rules'; export const name = 'Validate with no rules';
export const count = 10; export const count = 10;
const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml')); const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -10,7 +13,11 @@ const rebillyDocument = parseYamlToDocument(
readFileSync(rebillyDefinitionRef, 'utf-8'), readFileSync(rebillyDefinitionRef, 'utf-8'),
rebillyDefinitionRef rebillyDefinitionRef
); );
const config = makeConfigForRuleset({});
let config: StyleguideConfig;
export async function setupAsync() {
config = await makeConfigForRuleset({});
}
export function measureAsync() { export function measureAsync() {
return lintDocument({ return lintDocument({
externalRefResolver: new BaseResolver(), externalRefResolver: new BaseResolver(),

View File

@@ -4,6 +4,8 @@ import { lintDocument } from '../../lint';
import { BaseResolver } from '../../resolve'; import { BaseResolver } from '../../resolve';
import { parseYamlToDocument, makeConfigForRuleset } from '../utils'; import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
import type { StyleguideConfig } from '../../config';
export const name = 'Validate with single top-level rule and report'; export const name = 'Validate with single top-level rule and report';
export const count = 10; export const count = 10;
const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml')); const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -12,19 +14,22 @@ const rebillyDocument = parseYamlToDocument(
rebillyDefinitionRef rebillyDefinitionRef
); );
const config = makeConfigForRuleset({ let config: StyleguideConfig;
test: () => { export async function setupAsync() {
return { config = await makeConfigForRuleset({
Schema(schema, ctx) { test: () => {
if (schema.type === 'number') { return {
ctx.report({ Schema(schema, ctx) {
message: 'type number is not allowed', if (schema.type === 'number') {
}); ctx.report({
} message: 'type number is not allowed',
}, });
}; }
}, },
}); };
},
});
}
export function measureAsync() { export function measureAsync() {
return lintDocument({ return lintDocument({

View File

@@ -3,6 +3,9 @@ import { join as pathJoin, resolve as pathResolve } from 'path';
import { lintDocument } from '../../lint'; import { lintDocument } from '../../lint';
import { BaseResolver } from '../../resolve'; import { BaseResolver } from '../../resolve';
import { parseYamlToDocument, makeConfigForRuleset } from '../utils'; import { parseYamlToDocument, makeConfigForRuleset } from '../utils';
import type { StyleguideConfig } from '../../config';
export const name = 'Validate with single top-level rule'; export const name = 'Validate with single top-level rule';
export const count = 10; export const count = 10;
const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml')); const rebillyDefinitionRef = pathResolve(pathJoin(__dirname, 'rebilly.yaml'));
@@ -11,17 +14,20 @@ const rebillyDocument = parseYamlToDocument(
rebillyDefinitionRef rebillyDefinitionRef
); );
const config = makeConfigForRuleset({ let config: StyleguideConfig;
test: () => { export async function setupAsync() {
let count = 0; config = await makeConfigForRuleset({
return { test: () => {
Schema() { let count = 0;
count++; return {
if (count === -1) throw new Error('Disable optimization'); Schema() {
}, count++;
}; if (count === -1) throw new Error('Disable optimization');
}, },
}); };
},
});
}
export function measureAsync() { export function measureAsync() {
return lintDocument({ return lintDocument({

View File

@@ -26,6 +26,10 @@ if (require.main === module) {
assert(process.send); assert(process.send);
const module = require(modulePath); const module = require(modulePath);
if (module.setupAsync) {
await module.setupAsync();
}
if (module.measureAsync) { if (module.measureAsync) {
async function run() { async function run() {
await clockAsync(7, module.measureAsync); // warm up await clockAsync(7, module.measureAsync); // warm up

View File

@@ -13,20 +13,20 @@ export function parseYamlToDocument(body: string, absoluteRef: string = ''): Doc
}; };
} }
export function makeConfigForRuleset(rules: Oas3RuleSet, plugin?: Partial<Plugin>) { export async function makeConfigForRuleset(rules: Oas3RuleSet, plugin?: Partial<Plugin>) {
const rulesConf: Record<string, RuleConfig> = {}; const rulesConf: Record<string, RuleConfig> = {};
const ruleId = 'test'; const ruleId = 'test';
Object.keys(rules).forEach((name) => { Object.keys(rules).forEach((name) => {
rulesConf[`${ruleId}/${name}`] = 'error'; rulesConf[`${ruleId}/${name}`] = 'error';
}); });
const extendConfigs = [ const extendConfigs = [
resolvePlugins([ (await resolvePlugins([
{ {
...plugin, ...plugin,
id: ruleId, id: ruleId,
rules: { oas3: rules }, rules: { oas3: rules },
}, },
]) as ResolvedStyleguideConfig, ])) as ResolvedStyleguideConfig,
]; ];
if (rules) { if (rules) {
extendConfigs.push({ rules }); extendConfigs.push({ rules });

View File

@@ -91,6 +91,88 @@ describe('resolveStyleguideConfig', () => {
}); });
}); });
it('should resolve local file config with esm plugin', async () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-esm.yaml'],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(2);
const localPlugin = plugins?.find((p) => p.id === 'test-plugin');
expect(localPlugin).toBeDefined();
expect(localPlugin).toMatchObject({
id: 'test-plugin',
rules: {
oas3: {
'test-plugin/oas3-rule-name': 'oas3-rule-stub',
},
},
});
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config-with-esm.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin-esm.mjs',
]);
expect(styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
});
});
it('should resolve local file config with commonjs plugin with a default export function', async () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-commonjs-export-function.yaml'],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(2);
const localPlugin = plugins?.find((p) => p.id === 'test-plugin');
expect(localPlugin).toBeDefined();
expect(localPlugin).toMatchObject({
id: 'test-plugin',
rules: {
oas3: {
'test-plugin/oas3-rule-name': 'oas3-rule-stub',
},
},
});
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config-with-commonjs-export-function.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin-with-export-function.cjs',
]);
expect(styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
});
});
// TODO: fix circular test // TODO: fix circular test
it.skip('should throw circular error', () => { it.skip('should throw circular error', () => {
const config = { const config = {

View File

@@ -0,0 +1,2 @@
plugins:
- plugin-with-export-function.cjs

View File

@@ -0,0 +1,2 @@
plugins:
- plugin-esm.mjs

View File

@@ -0,0 +1,10 @@
export default async function () {
return {
id: 'test-plugin',
rules: {
oas3: {
'oas3-rule-name': 'oas3-rule-stub',
},
},
};
}

View File

@@ -0,0 +1,10 @@
module.exports = async function () {
return {
id: 'test-plugin',
rules: {
oas3: {
'oas3-rule-name': 'oas3-rule-stub',
},
},
};
};

View File

@@ -1,7 +1,10 @@
import * as path from 'path'; import * as path from 'path';
import { loadConfig } from '../load'; import { loadConfig } from '../load';
describe('resolving a plugin', () => { // FIXME: skipping due to the 'Test environment has been torn down' Jest error.
// Covered the below in the `miscellaneous/resolve-plugins` e2e tests.
// Unskip when Jest gets this fixed.
describe.skip('resolving a plugin', () => {
const configPath = path.join(__dirname, 'fixtures/plugin-config.yaml'); const configPath = path.join(__dirname, 'fixtures/plugin-config.yaml');
it('should prefix rule names with the plugin id', async () => { it('should prefix rule names with the plugin id', async () => {

View File

@@ -1,4 +1,5 @@
import * as path from 'path'; import * as path from 'path';
import { existsSync } from 'fs';
import { isAbsoluteUrl } from '../ref-utils'; import { isAbsoluteUrl } from '../ref-utils';
import { pickDefined, isNotString, isString, isDefined, keysOf } from '../utils'; import { pickDefined, isNotString, isString, isDefined, keysOf } from '../utils';
import { resolveDocument, BaseResolver } from '../resolve'; import { resolveDocument, BaseResolver } from '../resolve';
@@ -6,6 +7,8 @@ import { defaultPlugin } from './builtIn';
import { import {
getResolveConfig, getResolveConfig,
getUniquePlugins, getUniquePlugins,
isCommonJsPlugin,
isDeprecatedPluginFormat,
mergeExtends, mergeExtends,
parsePresetName, parsePresetName,
prefixRules, prefixRules,
@@ -27,12 +30,18 @@ import type {
ResolvedStyleguideConfig, ResolvedStyleguideConfig,
RuleConfig, RuleConfig,
DeprecatedInRawConfig, DeprecatedInRawConfig,
ImportedPlugin,
} from './types'; } from './types';
import type { Assertion, AssertionDefinition, RawAssertion } from '../rules/common/assertions'; import type { Assertion, AssertionDefinition, RawAssertion } from '../rules/common/assertions';
import type { Asserts, AssertionFn } from '../rules/common/assertions/asserts'; import type { Asserts, AssertionFn } from '../rules/common/assertions/asserts';
import type { BundleOptions } from '../bundle'; import type { BundleOptions } from '../bundle';
import type { Document, ResolvedRefMap } from '../resolve'; import type { Document, ResolvedRefMap } from '../resolve';
const DEFAULT_PROJECT_PLUGIN_PATHS = ['@theme/plugin.js', '@theme/plugin.cjs', '@theme/plugin.mjs'];
// 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)');
export async function resolveConfigFileAndRefs({ export async function resolveConfigFileAndRefs({
configPath, configPath,
externalRefResolver = new BaseResolver(), externalRefResolver = new BaseResolver(),
@@ -101,14 +110,24 @@ export async function resolveConfig({
); );
} }
export function resolvePlugins( function getDefaultPluginPath(configPath: string): string | undefined {
for (const pluginPath of DEFAULT_PROJECT_PLUGIN_PATHS) {
const absolutePluginPath = path.resolve(path.dirname(configPath), pluginPath);
if (existsSync(absolutePluginPath)) {
return pluginPath;
}
}
return undefined;
}
export async function resolvePlugins(
plugins: (string | Plugin)[] | null, plugins: (string | Plugin)[] | null,
configPath: string = '' configPath: string = ''
): Plugin[] { ): Promise<Plugin[]> {
if (!plugins) return []; if (!plugins) return [];
// TODO: implement or reuse Resolver approach so it will work in node and browser envs // TODO: implement or reuse Resolver approach so it will work in node and browser envs
const requireFunc = (plugin: string | Plugin): Plugin | undefined => { const requireFunc = async (plugin: string | Plugin): Promise<ImportedPlugin | undefined> => {
if (isBrowser && isString(plugin)) { if (isBrowser && isString(plugin)) {
logger.error(`Cannot load ${plugin}. Plugins aren't supported in browser yet.`); logger.error(`Cannot load ${plugin}. Plugins aren't supported in browser yet.`);
@@ -117,14 +136,24 @@ export function resolvePlugins(
if (isString(plugin)) { if (isString(plugin)) {
try { try {
const absoltePluginPath = path.resolve(path.dirname(configPath), plugin); const maybeAbsolutePluginPath = path.resolve(path.dirname(configPath), plugin);
const absolutePluginPath = existsSync(maybeAbsolutePluginPath)
? maybeAbsolutePluginPath
: // For plugins imported from packages specifically
require.resolve(plugin);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
return typeof __webpack_require__ === 'function' if (typeof __webpack_require__ === 'function') {
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
__non_webpack_require__(absoltePluginPath) return __non_webpack_require__(absolutePluginPath);
: require(absoltePluginPath); } else {
// you can import both cjs and mjs
const mod = await _importDynamic(absolutePluginPath);
return mod.default || mod;
}
} catch (e) { } catch (e) {
if (e instanceof SyntaxError) { if (e instanceof SyntaxError) {
throw e; throw e;
@@ -138,19 +167,47 @@ export function resolvePlugins(
const seenPluginIds = new Map<string, string>(); const seenPluginIds = new Map<string, string>();
return plugins /**
.map((p) => { * Include the default plugin automatically if it's not in configuration
if (isString(p) && isAbsoluteUrl(p)) { */
throw new Error(colorize.red(`We don't support remote plugins yet.`)); const defaultPluginPath = getDefaultPluginPath(configPath);
if (defaultPluginPath) {
plugins.push(defaultPluginPath);
}
const resolvedPlugins: Set<string> = new Set();
const instances = await Promise.all(
plugins.map(async (p) => {
if (isString(p)) {
if (isAbsoluteUrl(p)) {
throw new Error(colorize.red(`We don't support remote plugins yet.`));
}
if (resolvedPlugins.has(p)) {
return;
}
resolvedPlugins.add(p);
} }
// TODO: resolve npm packages similar to eslint const requiredPlugin: ImportedPlugin | undefined = await requireFunc(p);
const pluginModule = requireFunc(p);
const pluginCreatorOptions = { contentDir: path.dirname(configPath) };
const pluginModule = isDeprecatedPluginFormat(requiredPlugin)
? requiredPlugin
: isCommonJsPlugin(requiredPlugin)
? await requiredPlugin(pluginCreatorOptions)
: await requiredPlugin?.default?.(pluginCreatorOptions);
if (!pluginModule) { if (!pluginModule) {
return; return;
} }
if (isString(p) && pluginModule.id && isDeprecatedPluginFormat(requiredPlugin)) {
logger.info(`Deprecated plugin format detected: ${pluginModule.id}\n`);
}
const id = pluginModule.id; const id = pluginModule.id;
if (typeof id !== 'string') { if (typeof id !== 'string') {
throw new Error( throw new Error(
@@ -266,7 +323,9 @@ export function resolvePlugins(
return plugin; return plugin;
}) })
.filter(isDefined); );
return instances.filter(isDefined);
} }
export async function resolveApis({ export async function resolveApis({
@@ -317,7 +376,7 @@ async function resolveAndMergeNestedStyleguideConfig(
throw new Error(`Circular dependency in config file: "${configPath}"`); throw new Error(`Circular dependency in config file: "${configPath}"`);
} }
const plugins = getUniquePlugins( const plugins = getUniquePlugins(
resolvePlugins([...(styleguideConfig?.plugins || []), defaultPlugin], configPath) await resolvePlugins([...(styleguideConfig?.plugins || []), defaultPlugin], configPath)
); );
const pluginPaths = styleguideConfig?.plugins const pluginPaths = styleguideConfig?.plugins
?.filter(isString) ?.filter(isString)

View File

@@ -144,6 +144,22 @@ export type Plugin = {
assertions?: AssertionsConfig; assertions?: AssertionsConfig;
}; };
type PluginCreatorOptions = {
contentDir: string;
};
export type PluginCreator = (options: PluginCreatorOptions) => Plugin | Promise<Plugin>;
export type ImportedPlugin =
// ES Modules
| {
default?: PluginCreator;
}
// CommonJS
| PluginCreator
// Deprecated format
| Plugin;
export type PluginStyleguideConfig<T = undefined> = Omit< export type PluginStyleguideConfig<T = undefined> = Omit<
StyleguideRawConfig<T>, StyleguideRawConfig<T>,
'plugins' | 'extends' 'plugins' | 'extends'

View File

@@ -12,9 +12,9 @@ import type {
Api, Api,
DeprecatedInApi, DeprecatedInApi,
DeprecatedInRawConfig, DeprecatedInRawConfig,
ImportedPlugin,
FlatApi, FlatApi,
FlatRawConfig, FlatRawConfig,
Plugin,
RawConfig, RawConfig,
RawResolveConfig, RawResolveConfig,
ResolveConfig, ResolveConfig,
@@ -22,6 +22,8 @@ import type {
RulesFields, RulesFields,
StyleguideRawConfig, StyleguideRawConfig,
ThemeConfig, ThemeConfig,
Plugin,
PluginCreator,
} from './types'; } from './types';
export function parsePresetName(presetName: string): { pluginId: string; configName: string } { export function parsePresetName(presetName: string): { pluginId: string; configName: string } {
@@ -399,3 +401,11 @@ export class ConfigValidationError extends Error {}
export function deepCloneMapWithJSON<K, V>(originalMap: Map<K, V>): Map<K, V> { export function deepCloneMapWithJSON<K, V>(originalMap: Map<K, V>): Map<K, V> {
return new Map(JSON.parse(JSON.stringify([...originalMap]))); return new Map(JSON.parse(JSON.stringify([...originalMap])));
} }
export function isDeprecatedPluginFormat(plugin: ImportedPlugin | undefined): plugin is Plugin {
return plugin !== undefined && typeof plugin === 'object' && 'id' in plugin;
}
export function isCommonJsPlugin(plugin: ImportedPlugin | undefined): plugin is PluginCreator {
return typeof plugin === 'function';
}

View File

@@ -5,10 +5,19 @@ import { StyleguideConfig, defaultPlugin, resolvePlugins, resolvePreset } from '
import { BaseResolver } from '../../../../resolve'; import { BaseResolver } from '../../../../resolve';
const plugins = resolvePlugins([defaultPlugin]); import type { Plugin, ResolvedStyleguideConfig } from '../../../../config';
const pressets = resolvePreset('all', plugins);
const allConfig = new StyleguideConfig({ ...pressets, plugins });
describe('Oas3 Structural visitor basic', () => { describe('Oas3 Structural visitor basic', () => {
let plugins: Plugin[];
let presets: ResolvedStyleguideConfig;
let allConfig: StyleguideConfig;
beforeAll(async () => {
plugins = await resolvePlugins([defaultPlugin]);
presets = resolvePreset('all', plugins);
allConfig = new StyleguideConfig({ ...presets, plugins });
});
it('should report wrong types', async () => { it('should report wrong types', async () => {
const document = parseYamlToDocument( const document = parseYamlToDocument(
outdent` outdent`