Repository: Redocly/redocly-cli
Pull request #1284 (2023-10-04 09:26:46)

fix: document openapi-core package usage + slightly improve the API
## What/Why/How?

Folks are using our openapi-core package but it is not documented so they are stuck or end up using some internals or legacy functionality which is not intended for external use (for example: https://github.com/Redocly/redocly-cli/issues/1274).

This PR improves the public API slightly and also adds documentation the most common use cases so people can rely on the documented functionality.

This PR also adds an explicit warning to avoid using anything that is not documented. This will save time for both them and us.

We may want to move this documentation to our marketing site but I would prefer to keep it in the readme and to link here from main readme/marketing site.

## Reference

Related to https://github.com/Redocly/redocly-cli/issues/1274

## Testing

We need to cover all of the examples from basic usage section by tests. @tatomyr could you help me with that?

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows (internal)
- [ ] 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


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: cc45a98fea4873e55ce14e714c5b21939e1fb24a

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name                  | Type  |
| --------------------- | ----- |
| @redocly/openapi-core | Patch |
| @redocly/cli          | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Redocly/redocly-cli/new/chore/document-openapi-core-usage?filename=.changeset/lucky-planes-swim.md&value=---%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afix%3A%20document%20openapi-core%20package%20usage%20%2B%20slightly%20improve%20the%20API%0A)


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.707 ± 0.047 | 1.628 | 1.775 | 1.03 ± 0.06 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.663 ± 0.078 | 1.558 | 1.792 | 1.00 |

<!-- thollander/actions-comment-pull-request "hyperfine-comparison" -->
github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/6404116930) 
```
Error: The process '/opt/hostedtoolcache/node/18.18.0/x64/bin/npm' failed with exit code 1
```

| <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 | 75.86%     |    4001/5274    |
|                                                                           🟡                                                                           | Branches   | 65.76%     |    2130/3239    |
|                                                                           🟡                                                                           | Functions  | 67.78%     |     648/956     |
|                                                                           🟡                                                                           | Lines      | 76.04%     |    3749/4930    |


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

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File               | Statements                                                     | Branches                                                       | Functions                                                      | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------- | :------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------- |
|                                                                           🟢                                                                           | core/src/index.ts  | 100%                                                           | 100%                                                           | 7.41%                                                          | 100%                                                           |
|                                                                           🟡                                                                           | core/src/bundle.ts | <div title="Base coverage is: 75.18%">73.76% (-1.42% 🔻)</div> | <div title="Base coverage is: 65.31%">63.37% (-1.94% 🔻)</div> | <div title="Base coverage is: 92.86%">86.67% (-6.19% 🔻)</div> | <div title="Base coverage is: 75.18%">73.76% (-1.42% 🔻)</div> |

</details>

## Test suite run failed

<details><summary>Failed tests: 1/638. Failed suites: 1/94.</summary>
<br/>

```
  ● findConfig › should throw an error when found multiple config files

    expect(received).toThrow(expected)

    Expected substring: "
          Multiple configuration files are not allowed.·
          Found the following files: redocly.yaml, .redocly.yaml.·
          Please use 'redocly.yaml' instead.
        "
    Received message:   "
          Multiple configuration files are not allowed.
          Found the following files: redocly.yaml, .redocly.yaml.
          Please use 'redocly.yaml' instead.
        "

          107 |   ).filter(fs.existsSync);
          108 |   if (existingConfigFiles.length > 1) {
        > 109 |     throw new Error(`
              |           ^
          110 |       Multiple configuration files are not allowed.
          111 |       Found the following files: ${existingConfigFiles.join(', ')}.
          112 |       Please use 'redocly.yaml' instead.

                
      at findConfig (packages/core/src/config/load.ts:109:11)
      at Object.<anonymous> (node_modules/expect/build/toThrowMatchers.js:83:11)
      at Object.throwingMatcher [as toThrow] (node_modules/expect/build/index.js:338:21)
      at packages/core/src/config/__tests__/load.test.ts:75:24
      at packages/core/src/config/__tests__/load.test.ts:8:71
      at Object.<anonymous>.__awaiter (packages/core/src/config/__tests__/load.test.ts:4:12)
      at Object.<anonymous> (packages/core/src/config/__tests__/load.test.ts:71:75)
      at packages/core/src/config/__tests__/load.test.ts:75:24
      at packages/core/src/config/__tests__/load.test.ts:8:71
      at Object.<anonymous>.__awaiter (packages/core/src/config/__tests__/load.test.ts:4:12)
      at Object.<anonymous> (packages/core/src/config/__tests__/load.test.ts:71:75)

```

</details>


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

lornajane: Should I be able to run these code snippets? Tried the first one to get a sense of what the user gets back in `lintResults` but it tells me:
```
Promise { <pending> }
/home/lorna/temp/node_modules/@redocly/openapi-core/lib/lint.js:44
        const rules = config.getRulesForOasVersion(specMajorVersion);
                             ^

TypeError: Cannot read properties of undefined (reading 'getRulesForOasVersion')
```

What am I missing? I'm using a `redocly.yaml` file with a single rule in it, linting with CLI doesn't report config lint problems.
RomanHotsiy: I did not test all of those examples myself. The await was missing I think. I just fixed it above ☝️ 
lornajane: @tatomyr do we need to do anything in particular to make this README be used as the npm README? I have a vague memory of tripping over this in the past.
tatomyr: @lornajane it is already: https://www.npmjs.com/package/@redocly/openapi-core.

lornajane: What's left before we merge this? I approved, there's some changelog housekeeping and one thing I need someone to check it makes sense. Anything else? Can we make this happen?
tatomyr: @lornajane I'm trying to find some time to write a couple of tests 😅. Will try to finish as soon as possible.
RomanHotsiy: I'm okay to merge it whenever @tatomyr is ready.
tatomyr: I added the missing tests. @RomanHotsiy you may proceed further.
RomanHotsiy: > there's some changelog housekeeping and one thing I need someone to check it makes sense

@lornajane do you want to adjust changelog and what is the one thing? Or should I merge and you'll do it in a separate PR?
tatomyr: I've already adjusted the changeset.
lornajane: @RomanHotsiy this one thing: https://github.com/Redocly/redocly-cli/pull/1284#discussion_r1348571746 - I think `bundle.parsed` is where the user can find the bundled API description, is that right? I tried it and it seemed like this would be the right field to use.
RomanHotsiy: @lornajane yes, this is correct. I'm going ahead and merging the PR.

Please, cut a new release when you are ready.

Patch:
diff --git a/.changeset/two-shoes-sniff.md b/.changeset/two-shoes-sniff.md
new file mode 100644
index 00000000..fd05b74c
--- /dev/null
+++ b/.changeset/two-shoes-sniff.md
@@ -0,0 +1,5 @@
+---
+'@redocly/openapi-core': patch
+---
+
+fix: enhance public API by small typescript typing fix and exporting new function `bundleFromString`
diff --git a/jest.config.js b/jest.config.js
index ca59f74d..3f42c097 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -14,12 +14,12 @@ module.exports = {
     'packages/core/': {
       statements: 80,
       branches: 71,
-      functions: 70,
+      functions: 71,
       lines: 80,
     },
     'packages/cli/': {
       statements: 55,
-      branches: 47,
+      branches: 48,
       functions: 55,
       lines: 55,
     },
diff --git a/packages/core/README.md b/packages/core/README.md
index 75d4900b..9160552c 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -2,24 +2,267 @@
 
 See https://github.com/Redocly/redocly-cli
 
+> [!IMPORTANT]
+The `openapi-core package` is designed for our internal use; the interfaces that are considered safe to use are documented below.
+Some of the function arguments are not documented below because they are not intended for public use.
+Avoid using any functions or features that are not documented below.
+If your use case is not documented below, please open an issue.
+
 ## Basic usage
 
-### Lint
+### Lint from file system
+
+[Lint](https://redocly.com/docs/cli/commands/lint/) a file, optionally with a [config file](https://redocly.com/docs/cli/configuration/).
 
 ```js
-import { formatProblems, lint, loadConfig } from '@redocly/openapi-core';
+import { lint, loadConfig } from '@redocly/openapi-core';
 
 const pathToApi = 'openapi.yaml';
-const config = loadConfig({ configPath: 'optional/path/to/.redocly.yaml' });
+const config = await loadConfig({ configPath: 'optional/path/to/redocly.yaml' });
 const lintResults = await lint({ ref: pathToApi, config });
 ```
 
-### Bundle
+The content of `lintResults` describes any errors or warnings found during linting; an empty array means no problems were found.
+For each problem, the rule, severity, feedback message and a location object are provided.
+To learn more, [check the `lint` function section](#lint).
+
+### Bundle from file system
+
+[Bundle](https://redocly.com/docs/cli/commands/bundle/) an API description into a single structure, optionally with a [config file](https://redocly.com/docs/cli/configuration/).
 
 ```js
-import { formatProblems, bundle, loadConfig } from '@redocly/openapi-core';
+import { bundle, loadConfig } from '@redocly/openapi-core';
 
 const pathToApi = 'openapi.yaml';
-const config = loadConfig({ configPath: 'optional/path/to/.redocly.yaml' });
-const { bundle, problems } = await bundle({ ref: pathToApi, config });
+const config = await loadConfig({ configPath: 'optional/path/to/redocly.yaml' });
+const bundleResults = await bundle({ ref: pathToApi, config });
+```
+
+In `bundleResults`, the `bundle.parsed` field has the bundled API description.
+For more information, [check the `bundle` function section](#bundle).
+
+### Lint from memory with config
+
+[Lint](https://redocly.com/docs/cli/commands/lint/) an API description, with configuration defined. This is useful if the API description you're working with isn't a file on disk.
+
+```js
+import { lintFromString, createConfig, stringifyYaml } from '@redocly/openapi-core';
+
+const config = await createConfig(
+  {
+    extends: ['minimal'],
+    rules: {
+      'operation-description': 'error',
+    },
+  },
+  {
+    // optionally provide config path for resolving $refs and proper error locations
+    configPath: 'optional/path/to/redocly.yaml',
+  }
+);
+const source = stringifyYaml({ openapi: '3.0.1' /* ... */ }); // you can also use JSON.stringify
+const lintResults = await lintFromString({
+  source,
+  // optionally pass path to the file for resolving $refs and proper error locations
+  absoluteRef: 'optional/path/to/openapi.yaml',
+  config,
+});
+```
+
+### Lint from memory with a custom plugin
+
+[Lint](https://redocly.com/docs/cli/commands/lint/) an API description, with configuration including a [custom plugin](https://redocly.com/docs/cli/custom-plugins/) to define a rule.
+
+```js
+import { lintFromString, createConfig, stringifyYaml } from '@redocly/openapi-core';
+
+const CustomRule = (ruleOptions) => {
+  return {
+    Operation() {
+      // some rule logic
+    },
+  };
+};
+
+const config = await createConfig({
+  extends: ['recommended'],
+  plugins: [
+    {
+      id: 'pluginId',
+      rules: {
+        oas3: {
+          customRule1: CustomRule,
+        },
+        oas2: {
+          customRule1: CustomRule, // if the same rule can handle both oas3 and oas2
+        },
+      },
+      decorators: {
+        // ...
+      },
+    },
+  ],
+  // enable rule
+  rules: {
+    'pluginId/customRule1': 'error',
+  },
+  decorators: {
+    // ...
+  },
+});
+
+const source = stringifyYaml({ openapi: '3.0.1' /* ... */ }); // you can also use JSON.stringify
+const lintResults = await lintFromString({
+  source,
+  // optionally pass path to the file for resolving $refs and proper error locations
+  absoluteRef: 'optional/path/to/openapi.yaml',
+  config,
+});
+```
+
+### Bundle from memory
+
+[Bundle](https://redocly.com/docs/cli/commands/bundle/) an API description into a single structure, using default configuration.
+
+```js
+import { bundleFromString, createConfig } from '@redocly/openapi-core';
+
+const config = await createConfig({}); // create empty config
+const source = stringifyYaml({ openapi: '3.0.1' /* ... */ }); // you can also use JSON.stringify
+const bundleResults = await bundleFromString({
+  source,
+  // optionally pass path to the file for resolving $refs and proper error locations
+  absoluteRef: 'optional/path/to/openapi.yaml',
+  config,
+});
 ```
+
+## API
+
+### `createConfig`
+
+Creates a config object from a JSON or YAML string or JS object.
+Resolves remote config from `extends` (if there are URLs or local fs paths).
+
+```ts
+async function createConfig(
+  // JSON or YAML string or object with Redocly config
+  config: string | RawUniversalConfig,
+  options?: {
+    // optional path to the config file for resolving $refs and proper error locations
+    configPath?: string;
+  }
+): Promise<Config>;
+```
+
+### `loadConfig`
+
+Loads a config object from a file system. If `configPath` is not provided,
+it tries to find `redocly.yaml` in the current working directory.
+
+```ts
+async function loadConfig(options?: {
+  // optional path to the config file for resolving $refs and proper error locations
+  configPath?: string;
+  // allows to add custom `extends` additionally to one from the config file
+  customExtends?: string[];
+}): Promise<Config>;
+```
+
+### `lint`
+
+Lint an OpenAPI document from the file system.
+
+```ts
+async function lint(options: {
+  // path to the OpenAPI document root
+  ref: string;
+  // config object
+  config: Config;
+}): Promise<NormalizedProblem[]>;
+```
+
+### `lintFromString`
+
+Lint an OpenAPI document from a string.
+
+```ts
+async function lintFromString(options: {
+  // OpenAPI document string
+  source: string;
+  // optional path to the OpenAPI document for resolving $refs and proper error locations
+  absoluteRef?: string;
+  // config object
+  config: Config;
+}): Promise<NormalizedProblem[]>;
+```
+
+### `bundle`
+
+Bundle an OpenAPI document from the file system.
+
+```ts
+async function bundle(options: {
+  // path to the OpenAPI document root
+  ref: string;
+  // config object
+  config: Config;
+  // whether to fully dereference $refs, resulting document won't have any $ref
+  // warning: this can produce circular objects
+  dereference?: boolean;
+  // whether to remove unused components (schemas, parameters, responses, etc)
+  removeUnusedComponents?: boolean;
+  // whether to keep $ref pointers to the http URLs and resolve only local fs $refs
+  keepUrlRefs?: boolean;
+}): Promise<{
+    bundle: {
+      parsed: object; // OpenAPI document object as js object
+    };
+    problems: NormalizedProblem[]
+    fileDependencies
+    rootType
+    refTypes
+    visitorsData
+
+  }>;
+```
+
+### `bundleFromString`
+
+Bundle an OpenAPI document from a string.
+
+```ts
+async function bundleFromString(options: {
+  // OpenAPI document string
+  source: string;
+  // optional path to the OpenAPI document for resolving $refs and proper error locations
+  absoluteRef?: string;
+  // config object
+  config: Config;
+  // whether to fully dereference $refs, resulting document won't have any $ref
+  // warning: this can produce circular objects
+  dereference?: boolean;
+  // whether to remove unused components (schemas, parameters, responses, etc)
+  removeUnusedComponents?: boolean;
+  // whether to keep $ref pointers to the http URLs and resolve only local fs $refs
+  keepUrlRefs?: boolean;
+}): Promise<{
+    bundle: {
+      parsed: object; // OpenAPI document object as js object
+    };
+    problems: NormalizedProblem[]
+    fileDependencies
+    rootType
+    refTypes
+    visitorsData
+
+  }>;
+```
+
+### `stringifyYaml`
+
+Helper function to stringify a javascript object to YAML.
+
+```ts
+function stringifyYaml(obj: object): string;
+```
\ No newline at end of file
diff --git a/packages/core/src/bundle.ts b/packages/core/src/bundle.ts
index a0b412e5..1eed2bdf 100755
--- a/packages/core/src/bundle.ts
+++ b/packages/core/src/bundle.ts
@@ -1,5 +1,12 @@
 import isEqual = require('lodash.isequal');
-import { BaseResolver, resolveDocument, Document, ResolvedRefMap, makeRefId } from './resolve';
+import {
+  BaseResolver,
+  resolveDocument,
+  Document,
+  ResolvedRefMap,
+  makeRefId,
+  makeDocumentFromString,
+} from './resolve';
 import { Oas3Rule, normalizeVisitors, Oas3Visitor, Oas2Visitor } from './visitors';
 import { NormalizedNodeType, normalizeTypes, NodeType } from './types';
 import { WalkContext, walkDocument, UserContext, ResolveResult, NormalizedProblem } from './walk';
@@ -22,10 +29,7 @@ export enum OasVersion {
   Version3_0 = 'oas3_0',
   Version3_1 = 'oas3_1',
 }
-
-export async function bundle(opts: {
-  ref?: string;
-  doc?: Document;
+export type BundleOptions = {
   externalRefResolver?: BaseResolver;
   config: Config;
   dereference?: boolean;
@@ -33,7 +37,14 @@ export async function bundle(opts: {
   skipRedoclyRegistryRefs?: boolean;
   removeUnusedComponents?: boolean;
   keepUrlRefs?: boolean;
-}) {
+};
+
+export async function bundle(
+  opts: {
+    ref?: string;
+    doc?: Document;
+  } & BundleOptions
+) {
   const {
     ref,
     doc,
@@ -45,7 +56,7 @@ export async function bundle(opts: {
   }
 
   const document =
-    doc !== undefined ? doc : await externalRefResolver.resolveDocument(base, ref!, true);
+    doc === undefined ? await externalRefResolver.resolveDocument(base, ref!, true) : doc;
 
   if (document instanceof Error) {
     throw document;
@@ -59,6 +70,23 @@ export async function bundle(opts: {
   });
 }
 
+export async function bundleFromString(
+  opts: {
+    source: string;
+    absoluteRef?: string;
+  } & BundleOptions
+) {
+  const { source, absoluteRef, externalRefResolver = new BaseResolver(opts.config.resolve) } = opts;
+  const document = makeDocumentFromString(source, absoluteRef || '/');
+
+  return bundleDocument({
+    document,
+    ...opts,
+    externalRefResolver,
+    config: opts.config.styleguide,
+  });
+}
+
 type BundleContext = WalkContext;
 
 export type BundleResult = {
diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts
index 79c785ce..1cc64cf7 100644
--- a/packages/core/src/config/load.ts
+++ b/packages/core/src/config/load.ts
@@ -7,7 +7,13 @@ import { Config, DOMAINS } from './config';
 import { transformConfig } from './utils';
 import { resolveConfig } from './config-resolvers';
 
-import type { DeprecatedInRawConfig, FlatRawConfig, RawConfig, Region } from './types';
+import type {
+  DeprecatedInRawConfig,
+  FlatRawConfig,
+  RawConfig,
+  RawUniversalConfig,
+  Region,
+} from './types';
 import { RegionalTokenWithValidity } from '../redocly/redocly-client-types';
 
 async function addConfigMetadata({
@@ -101,8 +107,8 @@ export function findConfig(dir?: string): string | undefined {
   ).filter(fs.existsSync);
   if (existingConfigFiles.length > 1) {
     throw new Error(`
-      Multiple configuration files are not allowed. 
-      Found the following files: ${existingConfigFiles.join(', ')}. 
+      Multiple configuration files are not allowed.
+      Found the following files: ${existingConfigFiles.join(', ')}.
       Please use 'redocly.yaml' instead.
     `);
   }
@@ -129,10 +135,11 @@ export async function getConfig(
 type CreateConfigOptions = {
   extends?: string[];
   tokens?: RegionalTokenWithValidity[];
+  configPath?: string;
 };
 
 export async function createConfig(
-  config: string | RawConfig,
+  config: string | RawUniversalConfig,
   options?: CreateConfigOptions
 ): Promise<Config> {
   return addConfigMetadata({
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index cf2a3eab..f3844758 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -185,6 +185,9 @@ export type RawConfig = {
   telemetry?: Telemetry;
 } & ThemeConfig;
 
+// RawConfig is legacy, use RawUniversalConfig in public APIs
+export type RawUniversalConfig = Omit<RawConfig, 'styleguide'> & StyleguideRawConfig;
+
 export type FlatApi = Omit<Api, 'styleguide'> &
   Omit<ApiStyleguideRawConfig, 'doNotResolveExamples'>;
 
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6d801630..cfdf870d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -27,6 +27,7 @@ export {
   Config,
   StyleguideConfig,
   RawConfig,
+  RawUniversalConfig,
   IGNORE_FILE,
   Region,
   getMergedConfig,
@@ -75,4 +76,4 @@ export {
 export { getAstNodeByPointer, getLineColLocation } from './format/codeframes';
 export { formatProblems, OutputFormat, getTotals, Totals } from './format/format';
 export { lint, lint as validate, lintDocument, lintFromString, lintConfig } from './lint';
-export { bundle, bundleDocument, mapTypeToComponent } from './bundle';
+export { bundle, bundleDocument, mapTypeToComponent, bundleFromString } from './bundle';


Test patch:
diff --git a/packages/core/src/__tests__/__snapshots__/bundle.test.ts.snap b/packages/core/src/__tests__/__snapshots__/bundle.test.ts.snap
index ba26e4f0..86b3f6fa 100644
--- a/packages/core/src/__tests__/__snapshots__/bundle.test.ts.snap
+++ b/packages/core/src/__tests__/__snapshots__/bundle.test.ts.snap
@@ -308,7 +308,7 @@ components:
 
 `;
 
-exports[`bundle should not place referened schema inline when component in question is not of type "schemas" 1`] = `
+exports[`bundle should not place referenced schema inline when component in question is not of type "schemas" 1`] = `
 openapi: 3.0.0
 paths:
   /pet:
diff --git a/packages/core/src/__tests__/bundle.test.ts b/packages/core/src/__tests__/bundle.test.ts
index 181494e9..99a73fd0 100644
--- a/packages/core/src/__tests__/bundle.test.ts
+++ b/packages/core/src/__tests__/bundle.test.ts
@@ -1,11 +1,32 @@
 import outdent from 'outdent';
 import * as path from 'path';
 
-import { bundleDocument, bundle } from '../bundle';
+import { bundleDocument, bundle, bundleFromString } from '../bundle';
 import { parseYamlToDocument, yamlSerializer, makeConfig } from '../../__tests__/utils';
-import { StyleguideConfig, Config, ResolvedConfig } from '../config';
+import { StyleguideConfig, Config, ResolvedConfig, createConfig, loadConfig } from '../config';
 import { BaseResolver } from '../resolve';
 
+const stringDocument = outdent`
+  openapi: 3.0.0
+  paths:
+    /pet:
+      get:
+        operationId: get
+        parameters:
+          - $ref: '#/components/parameters/shared_a'
+          - name: get_b
+      post:
+        operationId: post
+        parameters:
+          - $ref: '#/components/parameters/shared_a'
+  components:
+    parameters:
+      shared_a:
+        name: shared-a
+`;
+
+const testDocument = parseYamlToDocument(stringDocument, '');
+
 describe('bundle', () => {
   const fetchMock = jest.fn(() =>
     Promise.resolve({
@@ -19,28 +40,6 @@ describe('bundle', () => {
 
   expect.addSnapshotSerializer(yamlSerializer);
 
-  const testDocument = parseYamlToDocument(
-    outdent`
-      openapi: 3.0.0
-      paths:
-        /pet:
-          get:
-            operationId: get
-            parameters:
-              - $ref: '#/components/parameters/shared_a'
-              - name: get_b
-          post:
-            operationId: post
-            parameters:
-              - $ref: '#/components/parameters/shared_a'
-      components:
-        parameters:
-          shared_a:
-            name: shared-a
-    `,
-    ''
-  );
-
   it('change nothing with only internal refs', async () => {
     const { bundle, problems } = await bundleDocument({
       document: testDocument,
@@ -97,7 +96,7 @@ describe('bundle', () => {
     expect(res.parsed).toMatchSnapshot();
   });
 
-  it('should not place referened schema inline when component in question is not of type "schemas"', async () => {
+  it('should not place referenced schema inline when component in question is not of type "schemas"', async () => {
     const { bundle: res, problems } = await bundle({
       config: new Config({} as ResolvedConfig),
       ref: path.join(__dirname, 'fixtures/refs/external-request-body.yaml'),
@@ -233,4 +232,45 @@ describe('bundle', () => {
 
     `);
   });
+
+  it('should throw an error when there is no document to bundle', () => {
+    const wrapper = () =>
+      bundle({
+        config: new Config({} as ResolvedConfig),
+      });
+
+    expect(wrapper()).rejects.toThrowError('Document or reference is required.\n');
+  });
+
+  it('should bundle with a doc provided', async () => {
+    const {
+      bundle: { parsed },
+      problems,
+    } = await bundle({
+      config: await loadConfig({ configPath: path.join(__dirname, 'fixtures/redocly.yaml') }),
+      doc: testDocument,
+    });
+
+    const origCopy = JSON.parse(JSON.stringify(testDocument.parsed));
+
+    expect(problems).toHaveLength(0);
+    expect(parsed).toEqual(origCopy);
+  });
+});
+
+describe('bundleFromString', () => {
+  it('should bundle from string using bundleFromString', async () => {
+    const {
+      bundle: { parsed, ...rest },
+      problems,
+    } = await bundleFromString({
+      config: await createConfig(`
+        extends:
+        - recommended
+      `),
+      source: testDocument.source.body,
+    });
+    expect(problems).toHaveLength(0);
+    expect(rest.source.body).toEqual(stringDocument);
+  });
 });
diff --git a/packages/core/src/__tests__/fixtures/lint/openapi.yaml b/packages/core/src/__tests__/fixtures/lint/openapi.yaml
new file mode 100644
index 00000000..ec573bac
--- /dev/null
+++ b/packages/core/src/__tests__/fixtures/lint/openapi.yaml
@@ -0,0 +1,10 @@
+openapi: 3.0.0
+info:
+  title: Test API
+  version: '1.0'
+  description: Test
+  license: Fail
+
+servers:
+  - url: http://redocly-example.com
+paths: {}
diff --git a/packages/core/src/__tests__/fixtures/redocly.yaml b/packages/core/src/__tests__/fixtures/redocly.yaml
new file mode 100644
index 00000000..c2373f57
--- /dev/null
+++ b/packages/core/src/__tests__/fixtures/redocly.yaml
@@ -0,0 +1,2 @@
+extends:
+  - recommended
diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts
index c0e4dceb..20efde02 100644
--- a/packages/core/src/__tests__/lint.test.ts
+++ b/packages/core/src/__tests__/lint.test.ts
@@ -1,7 +1,7 @@
 import * as path from 'path';
 import { outdent } from 'outdent';
 
-import { lintFromString, lintConfig, lintDocument } from '../lint';
+import { lintFromString, lintConfig, lintDocument, lint } from '../lint';
 import { BaseResolver } from '../resolve';
 import { loadConfig } from '../config/load';
 import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../__tests__/utils';
@@ -46,6 +46,35 @@ describe('lint', () => {
     `);
   });
 
+  it('lint should work', async () => {
+    const results = await lint({
+      ref: path.join(__dirname, 'fixtures/lint/openapi.yaml'),
+      config: await loadConfig({
+        configPath: path.join(__dirname, 'fixtures/redocly.yaml'),
+      }),
+    });
+
+    expect(replaceSourceWithRef(results, path.join(__dirname, 'fixtures/lint/')))
+      .toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "from": undefined,
+          "location": Array [
+            Object {
+              "pointer": "#/info/license",
+              "reportOnKey": false,
+              "source": "openapi.yaml",
+            },
+          ],
+          "message": "Expected type \`License\` (object) but got \`string\`",
+          "ruleId": "spec",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+
   it('lintConfig should work', async () => {
     const document = parseYamlToDocument(
       outdent`
diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts
index a15bf5a9..f608a1f1 100644
--- a/packages/core/src/config/__tests__/load.test.ts
+++ b/packages/core/src/config/__tests__/load.test.ts
@@ -73,8 +73,8 @@ describe('findConfig', () => {
       .spyOn(fs, 'existsSync')
       .mockImplementation((name) => name === 'redocly.yaml' || name === '.redocly.yaml');
     expect(findConfig).toThrow(`
-      Multiple configuration files are not allowed. 
-      Found the following files: redocly.yaml, .redocly.yaml. 
+      Multiple configuration files are not allowed.
+      Found the following files: redocly.yaml, .redocly.yaml.
       Please use 'redocly.yaml' instead.
     `);
   });
@@ -124,6 +124,39 @@ describe('createConfig', () => {
       overridesRules: rawConfig.rules as Record<string, RuleConfig>,
     });
   });
+
+  it('should create config from object with a custom plugin', async () => {
+    const testCustomRule = jest.fn();
+    const rawConfig: FlatRawConfig = {
+      extends: [],
+      plugins: [
+        {
+          id: 'my-plugin',
+          rules: {
+            oas3: {
+              'test-rule': testCustomRule,
+            },
+          },
+        },
+      ],
+      rules: {
+        'my-plugin/test-rule': 'error',
+      },
+    };
+    const config = await createConfig(rawConfig);
+
+    expect(config.styleguide.plugins[0]).toEqual({
+      id: 'my-plugin',
+      rules: {
+        oas3: {
+          'my-plugin/test-rule': testCustomRule,
+        },
+      },
+    });
+    expect(config.styleguide.rules.oas3_0).toEqual({
+      'my-plugin/test-rule': 'error',
+    });
+  });
 });
 
 function verifyExtendedConfig(