fix: document openapi-core package usage + slightly improve the API (#1284)

* fix: document openapi-core package usage + slightly improve the API

* chore: fix tests

* Apply suggestions from code review

* docs: test code and add some explanations for each section

* fix: make the bundle output variables match in both examples

* chore: add tests for lint and bundle

* chore: add tests for bundleFromString and createConfig with a plugin

* chore: apply prettier

* Update packages/core/src/__tests__/bundle.test.ts

---------

Co-authored-by: Lorna Mitchell <lorna.mitchell@redocly.com>
Co-authored-by: Andrew Tatomyr <andrew.tatomyr@redocly.com>
This commit is contained in:
Roman Hotsiy
2023-10-16 07:38:40 +08:00
committed by GitHub
parent be16c5898a
commit 4d66ace6b8
13 changed files with 451 additions and 50 deletions

View File

@@ -0,0 +1,5 @@
---
'@redocly/openapi-core': patch
---
fix: enhance public API by small typescript typing fix and exporting new function `bundleFromString`

View File

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

View File

@@ -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;
```

View File

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

View File

@@ -1,26 +1,12 @@
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';
describe('bundle', () => {
const fetchMock = jest.fn(() =>
Promise.resolve({
ok: true,
text: () => 'External schema content',
headers: {
get: () => '',
},
})
);
expect.addSnapshotSerializer(yamlSerializer);
const testDocument = parseYamlToDocument(
outdent`
const stringDocument = outdent`
openapi: 3.0.0
paths:
/pet:
@@ -37,10 +23,23 @@ describe('bundle', () => {
parameters:
shared_a:
name: shared-a
`,
''
`;
const testDocument = parseYamlToDocument(stringDocument, '');
describe('bundle', () => {
const fetchMock = jest.fn(() =>
Promise.resolve({
ok: true,
text: () => 'External schema content',
headers: {
get: () => '',
},
})
);
expect.addSnapshotSerializer(yamlSerializer);
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);
});
});

View File

@@ -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: {}

View File

@@ -0,0 +1,2 @@
extends:
- recommended

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'>;

View File

@@ -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';