feat: openapi-core in browser support (#811)

* feat: use openapi-core in browser

Co-authored-by: Andrew Tatomyr <andrew.tatomyr@redocly.com>
This commit is contained in:
Roman Sainchuk
2022-08-22 14:10:12 +03:00
committed by GitHub
parent 8eaebe388a
commit f83e05076a
18 changed files with 296 additions and 67 deletions

View File

@@ -1,4 +1,5 @@
coverage/
dist/
packages/cli/lib/
packages/core/lib/
*snapshot.js

View File

@@ -2,6 +2,7 @@ module.exports = {
clearMocks: true,
restoreMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverageFrom: [
'packages/*/src/**/*.ts',
'!packages/**/__tests__/**/*',

View File

@@ -0,0 +1,53 @@
/**
* @jest-environment jsdom
*/
import * as colorette from 'colorette';
import { logger, colorize } from '../logger';
describe('Logger in Browser', () => {
it('should call "console.error"', () => {
const error = jest.spyOn(console, 'error').mockImplementation();
logger.error('error');
expect(error).toBeCalledTimes(1);
expect(error).toBeCalledWith('error');
error.mockRestore();
});
it('should call "console.log"', () => {
const log = jest.spyOn(console, 'log').mockImplementation();
logger.info('info');
expect(log).toBeCalledTimes(1);
expect(log).toBeCalledWith('info');
log.mockRestore();
});
it('should call "console.warn"', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation();
logger.warn('warn');
expect(warn).toBeCalledTimes(1);
expect(warn).toBeCalledWith('warn');
warn.mockRestore();
});
});
describe('colorize in Browser', () => {
it('should not call original colorette lib', () => {
const color = 'cyan';
const spyingCyan = jest.spyOn(colorette, color);
const colorized = colorize.cyan(color);
expect(spyingCyan).not.toBeCalled();
expect(colorized).toEqual(color);
});
});

View File

@@ -0,0 +1,47 @@
import * as colorette from 'colorette';
import { logger, colorize } from '../logger';
describe('Logger in nodejs', () => {
let spyingStderr: jest.SpyInstance;
beforeEach(() => {
spyingStderr = jest.spyOn(process.stderr, 'write').mockImplementation();
});
afterEach(() => {
spyingStderr.mockRestore();
});
it('should call "process.stderr.write" for error severity', () => {
logger.error('error');
expect(spyingStderr).toBeCalledTimes(1);
expect(spyingStderr).toBeCalledWith(colorette.red('error'));
});
it('should call "process.stderr.write" for warn severity', () => {
logger.warn('warn');
expect(spyingStderr).toBeCalledTimes(1);
expect(spyingStderr).toBeCalledWith(colorette.yellow('warn'));
});
it('should call "process.stderr.write" for info severity', () => {
logger.info('info');
expect(spyingStderr).toBeCalledTimes(1);
expect(spyingStderr).toBeCalledWith('info');
});
});
describe('colorize in nodejs', () => {
it('should call original colorette lib', () => {
const color = 'cyan';
const spyingCyan = jest.spyOn(colorette, color);
const colorized = colorize.cyan(color);
expect(spyingCyan).toBeCalledWith(color);
expect(colorized).toEqual(colorette[color](color));
});
});

View File

@@ -0,0 +1,18 @@
/**
* @jest-environment jsdom
*/
import { output } from '../output';
describe('output', () => {
it('should ignore all parsable data in browser', () => {
const spyingStdout = jest.spyOn(process.stdout, 'write').mockImplementation();
const data = '{ "errors" : [] }';
output.write(data);
expect(spyingStdout).not.toBeCalled();
spyingStdout.mockRestore();
});
});

View File

@@ -0,0 +1,15 @@
import { output } from '../output';
describe('output', () => {
it('should write all parsable data to stdout', () => {
const spyingStdout = jest.spyOn(process.stdout, 'write').mockImplementation();
const data = '{ "errors" : [] }';
output.write(data);
expect(spyingStdout).toBeCalledTimes(1);
expect(spyingStdout).toBeCalledWith(data);
spyingStdout.mockRestore();
});
});

View File

@@ -0,0 +1,11 @@
/**
* @jest-environment jsdom
*/
import { isBrowser } from '../env';
describe('isBrowser', () => {
it('should be browser', () => {
expect(isBrowser).toBe(true);
});
});

View File

@@ -5,6 +5,7 @@ import {
getMatchingStatusCodeRange,
doesYamlFileExist,
} from '../utils';
import { isBrowser } from '../env';
import * as fs from 'fs';
import * as path from 'path';
@@ -122,5 +123,11 @@ describe('utils', () => {
expect(doesYamlFileExist('redocly.yam')).toBe(false);
});
});
describe('isBrowser', () => {
it('should not be browser', () => {
expect(isBrowser).toBe(false);
});
});
});
});

View File

@@ -1,5 +1,4 @@
import * as path from 'path';
import { blue, red } from 'colorette';
import { isAbsoluteUrl } from '../ref-utils';
import { BaseResolver } from '../resolve';
import { defaultPlugin } from './builtIn';
@@ -21,8 +20,10 @@ import type {
RuleConfig,
DeprecatedInRawConfig,
} from './types';
import { isBrowser } from '../env';
import { isNotString, isString, notUndefined, parseYaml } from '../utils';
import { Config } from './config';
import { colorize, logger } from '../logger';
export async function resolveConfig(rawConfig: RawConfig, configPath?: string): Promise<Config> {
if (rawConfig.styleguide?.extends?.some(isNotString)) {
@@ -71,35 +72,57 @@ export function resolvePlugins(
): Plugin[] {
if (!plugins) return [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require;
// TODO: implement or reuse Resolver approach so it will work in node and browser envs
const requireFunc = (plugin: string | Plugin): Plugin | undefined => {
if (isBrowser && isString(plugin)) {
logger.error(`Cannot load ${plugin}. Plugins aren't supported in browser yet.`);
return undefined;
}
if (isString(plugin)) {
const absoltePluginPath = path.resolve(path.dirname(configPath), plugin);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return typeof __webpack_require__ === 'function'
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
__non_webpack_require__(absoltePluginPath)
: require(absoltePluginPath);
}
return plugin;
};
const seenPluginIds = new Map<string, string>();
return plugins
.map((p) => {
if (isString(p) && isAbsoluteUrl(p)) {
throw new Error(red(`We don't support remote plugins yet.`));
throw new Error(colorize.red(`We don't support remote plugins yet.`));
}
// TODO: resolve npm packages similar to eslint
const pluginModule = isString(p)
? (requireFunc(path.resolve(path.dirname(configPath), p)) as Plugin)
: p;
const pluginModule = requireFunc(p);
if (!pluginModule) {
return;
}
const id = pluginModule.id;
if (typeof id !== 'string') {
throw new Error(red(`Plugin must define \`id\` property in ${blue(p.toString())}.`));
throw new Error(
colorize.red(`Plugin must define \`id\` property in ${colorize.blue(p.toString())}.`)
);
}
if (seenPluginIds.has(id)) {
const pluginPath = seenPluginIds.get(id)!;
throw new Error(
red(
`Plugin "id" must be unique. Plugin ${blue(p.toString())} uses id "${blue(
id
)}" already seen in ${blue(pluginPath)}`
colorize.red(
`Plugin "id" must be unique. Plugin ${colorize.blue(
p.toString()
)} uses id "${colorize.blue(id)}" already seen in ${colorize.blue(pluginPath)}`
)
);
}
@@ -285,17 +308,19 @@ export function resolvePreset(presetName: string, plugins: Plugin[]): ResolvedSt
const { pluginId, configName } = parsePresetName(presetName);
const plugin = plugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(`Invalid config ${red(presetName)}: plugin ${pluginId} is not included.`);
throw new Error(
`Invalid config ${colorize.red(presetName)}: plugin ${pluginId} is not included.`
);
}
const preset = plugin.configs?.[configName];
if (!preset) {
throw new Error(
pluginId
? `Invalid config ${red(
? `Invalid config ${colorize.red(
presetName
)}: plugin ${pluginId} doesn't export config with name ${configName}.`
: `Invalid config ${red(presetName)}: there is no such built-in config.`
: `Invalid config ${colorize.red(presetName)}: there is no such built-in config.`
);
}
return preset;

View File

@@ -4,6 +4,7 @@ import { parseYaml, stringifyYaml } from '../js-yaml';
import { slash, doesYamlFileExist } from '../utils';
import { NormalizedProblem } from '../walk';
import { OasVersion, OasMajorVersion, Oas2RuleSet, Oas3RuleSet } from '../oas-types';
import { env } from '../env';
import type { NodeType } from '../types';
import type {
@@ -19,9 +20,6 @@ import type {
} from './types';
import { getResolveConfig } from './utils';
// Alias environment here so this file can work in browser environments too.
export const env = typeof process !== 'undefined' ? process.env || {} : {};
export const IGNORE_FILE = '.redocly.lint-ignore.yaml';
const IGNORE_BANNER =
`# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.\n` +

View File

@@ -1,4 +1,3 @@
import { yellow } from 'colorette';
import {
assignExisting,
isTruthy,
@@ -18,6 +17,7 @@ import type {
ResolvedStyleguideConfig,
RulesFields,
} from './types';
import { logger, colorize } from '../logger';
export function parsePresetName(presetName: string): { pluginId: string; configName: string } {
if (presetName.indexOf('/') > -1) {
@@ -222,7 +222,7 @@ export function getUniquePlugins(plugins: Plugin[]): Plugin[] {
results.push(p);
seen.add(p.id);
} else if (p.id) {
process.stderr.write(`Duplicate plugin id "${yellow(p.id)}".\n`);
logger.warn(`Duplicate plugin id "${colorize.red(p.id)}".\n`);
}
}
return results;

5
packages/core/src/env.ts Normal file
View File

@@ -0,0 +1,5 @@
export const isBrowser =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
typeof window !== 'undefined' || typeof self !== 'undefined' || typeof process === 'undefined'; // main and worker thread
export const env = isBrowser ? {} : process.env || {};

View File

@@ -1,7 +1,7 @@
import { gray, red, options as colorOptions } from 'colorette';
import * as yamlAst from 'yaml-ast-parser';
import { unescapePointer } from '../ref-utils';
import { LineColLocationObject, Loc, LocationObject } from '../walk';
import { colorize, colorOptions } from '../logger';
type YAMLMapping = yamlAst.YAMLMapping & { kind: yamlAst.Kind.MAPPING };
type YAMLMap = yamlAst.YamlMap & { kind: yamlAst.Kind.MAP };
@@ -39,14 +39,20 @@ export function getCodeframe(location: LineColLocationObject, color: boolean) {
const startIdx = i === startLineNum ? start.col - 1 : currentPad;
const endIdx = i === endLineNum ? end.col - 1 : line.length;
prefixedLines.push([`${i}`, markLine(line, startIdx, endIdx, red)]);
prefixedLines.push([`${i}`, markLine(line, startIdx, endIdx, colorize.red)]);
if (!color) prefixedLines.push(['', underlineLine(line, startIdx, endIdx)]);
}
if (skipLines > 0) {
prefixedLines.push([``, `${whitespace(currentPad)}${gray(`< ${skipLines} more lines >`)}`]);
prefixedLines.push([
``,
`${whitespace(currentPad)}${colorize.gray(`< ${skipLines} more lines >`)}`,
]);
// print last line
prefixedLines.push([`${endLineNum}`, markLine(lines[endLineNum - 1], -1, end.col - 1, red)]);
prefixedLines.push([
`${endLineNum}`,
markLine(lines[endLineNum - 1], -1, end.col - 1, colorize.red),
]);
if (!color) prefixedLines.push(['', underlineLine(lines[endLineNum - 1], -1, end.col - 1)]);
}
@@ -63,7 +69,7 @@ export function getCodeframe(location: LineColLocationObject, color: boolean) {
line: string,
startIdx: number = -1,
endIdx: number = +Infinity,
variant = gray
variant = colorize.gray
) {
if (!color) return line;
if (!line) return line;
@@ -90,7 +96,7 @@ function printPrefixedLines(lines: [string, string][]): string {
return existingLines
.map(
([prefix, line]) =>
gray(leftPad(padLen, prefix) + ' |') +
colorize.gray(leftPad(padLen, prefix) + ' |') +
(line ? ' ' + limitLineLength(line.substring(dedentLen)) : '')
)
.join('\n');
@@ -99,7 +105,7 @@ function printPrefixedLines(lines: [string, string][]): string {
function limitLineLength(line: string, maxLen: number = MAX_LINE_LENGTH) {
const overflowLen = line.length - maxLen;
if (overflowLen > 0) {
const charsMoreText = gray(`...<${overflowLen} chars>`);
const charsMoreText = colorize.gray(`...<${overflowLen} chars>`);
return line.substring(0, maxLen - charsMoreText.length) + charsMoreText;
} else {
return line;

View File

@@ -1,20 +1,12 @@
import * as path from 'path';
import {
options as colorOptions,
gray,
blue,
bgRed,
bgYellow,
black,
yellow,
red,
} from 'colorette';
import { colorOptions, colorize, logger } from '../logger';
import { output } from '../output';
const coreVersion = require('../../package.json').version;
import { NormalizedProblem, ProblemSeverity, LineColLocationObject, LocationObject } from '../walk';
import { getCodeframe, getLineColLocation } from './codeframes';
import { env } from '../config';
import { env } from '../env';
export type Totals = {
errors: number;
@@ -27,13 +19,13 @@ const ERROR_MESSAGE = {
};
const BG_COLORS = {
warn: (str: string) => bgYellow(black(str)),
error: bgRed,
warn: (str: string) => colorize.bgYellow(colorize.black(str)),
error: colorize.bgRed,
};
const COLORS = {
warn: yellow,
error: red,
warn: colorize.yellow,
error: colorize.red,
};
const SEVERITY_NAMES = {
@@ -114,7 +106,7 @@ export function formatProblems(
case 'codeframe':
for (let i = 0; i < problems.length; i++) {
const problem = problems[i];
process.stderr.write(`${formatCodeframe(problem, i)}\n`);
logger.info(`${formatCodeframe(problem, i)}\n`);
}
break;
case 'stylish': {
@@ -122,30 +114,30 @@ export function formatProblems(
for (const [file, { ruleIdPad, locationPad: positionPad, fileProblems }] of Object.entries(
groupedByFile
)) {
process.stderr.write(`${blue(path.relative(cwd, file))}:\n`);
logger.info(`${colorize.blue(path.relative(cwd, file))}:\n`);
for (let i = 0; i < fileProblems.length; i++) {
const problem = fileProblems[i];
process.stderr.write(`${formatStylish(problem, positionPad, ruleIdPad)}\n`);
logger.info(`${formatStylish(problem, positionPad, ruleIdPad)}\n`);
}
process.stderr.write('\n');
logger.info('\n');
}
break;
}
case 'checkstyle': {
const groupedByFile = groupByFiles(problems);
process.stdout.write('<?xml version="1.0" encoding="UTF-8"?>\n');
process.stdout.write('<checkstyle version="4.3">\n');
output.write('<?xml version="1.0" encoding="UTF-8"?>\n');
output.write('<checkstyle version="4.3">\n');
for (const [file, { fileProblems }] of Object.entries(groupedByFile)) {
process.stdout.write(`<file name="${xmlEscape(path.relative(cwd, file))}">\n`);
output.write(`<file name="${xmlEscape(path.relative(cwd, file))}">\n`);
fileProblems.forEach(formatCheckstyle);
process.stdout.write(`</file>\n`);
output.write(`</file>\n`);
}
process.stdout.write(`</checkstyle>\n`);
output.write(`</checkstyle>\n`);
break;
}
case 'codeclimate':
@@ -154,8 +146,8 @@ export function formatProblems(
}
if (totalProblems - ignoredProblems > maxProblems) {
process.stderr.write(
`< ... ${totalProblems - maxProblems} more problems hidden > ${gray(
logger.info(
`< ... ${totalProblems - maxProblems} more problems hidden > ${colorize.gray(
'increase with `--max-problems N`'
)}\n`
);
@@ -177,7 +169,7 @@ export function formatProblems(
fingerprint: `${p.ruleId}${p.location.length > 0 ? '-' + p.location[0].pointer : ''}`,
};
});
process.stdout.write(JSON.stringify(issues, null, 2));
output.write(JSON.stringify(issues, null, 2));
}
function outputJSON() {
@@ -211,7 +203,7 @@ export function formatProblems(
return problem;
}),
};
process.stdout.write(JSON.stringify(resultObject, null, 2));
output.write(JSON.stringify(resultObject, null, 2));
}
function getBgColor(problem: NormalizedProblem) {
@@ -227,7 +219,7 @@ export function formatProblems(
const location = problem.location[0]; // TODO: support multiple locations
const relativePath = path.relative(cwd, location.source.absoluteRef);
const loc = getLineColLocation(location);
const atPointer = location.pointer ? gray(`at ${location.pointer}`) : '';
const atPointer = location.pointer ? colorize.gray(`at ${location.pointer}`) : '';
const fileWithLoc = `${relativePath}:${loc.start.line}:${loc.start.col}`;
return (
`[${idx + 1}] ${bgColor(fileWithLoc)} ${atPointer}\n\n` +
@@ -236,7 +228,9 @@ export function formatProblems(
getCodeframe(loc, color) +
'\n\n' +
formatFrom(cwd, problem.from) +
`${SEVERITY_NAMES[problem.severity]} was generated by the ${blue(problem.ruleId)} rule.\n\n`
`${SEVERITY_NAMES[problem.severity]} was generated by the ${colorize.blue(
problem.ruleId
)} rule.\n\n`
);
}
@@ -257,7 +251,7 @@ export function formatProblems(
const severity = problem.severity == 'warn' ? 'warning' : 'error';
const message = xmlEscape(problem.message);
const source = xmlEscape(problem.ruleId);
process.stdout.write(
output.write(
`<error line="${line}" column="${col}" severity="${severity}" message="${message}" source="${source}" />\n`
);
}
@@ -269,7 +263,7 @@ function formatFrom(cwd: string, location?: LocationObject) {
const loc = getLineColLocation(location);
const fileWithLoc = `${relativePath}:${loc.start.line}:${loc.start.col}`;
return `referenced from ${blue(fileWithLoc)}\n\n`;
return `referenced from ${colorize.blue(fileWithLoc)}\n\n`;
}
function formatDidYouMean(problem: NormalizedProblem) {

View File

@@ -0,0 +1,34 @@
import * as colorette from 'colorette';
export { options as colorOptions } from 'colorette';
import { isBrowser } from './env';
import { identity } from './utils';
export const colorize = new Proxy(colorette, {
get(target: typeof colorette, prop: string): typeof identity {
if (isBrowser) {
return identity;
}
return (target as any)[prop];
},
});
class Logger {
protected stderr(str: string) {
return process.stderr.write(str);
}
info(str: string) {
return isBrowser ? console.log(str) : this.stderr(str);
}
warn(str: string) {
return isBrowser ? console.warn(str) : this.stderr(colorize.yellow(str));
}
error(str: string) {
return isBrowser ? console.error(str) : this.stderr(colorize.red(str));
}
}
export const logger = new Logger();

View File

@@ -0,0 +1,7 @@
import { isBrowser } from './env';
export const output = {
write(str: string) {
return isBrowser ? undefined : process.stdout.write(str);
},
};

View File

@@ -1,11 +1,12 @@
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import { green } from 'colorette';
import { RegistryApi } from './registry-api';
import { DEFAULT_REGION, DOMAINS, AVAILABLE_REGIONS, env } from '../config/config';
import { DEFAULT_REGION, DOMAINS, AVAILABLE_REGIONS } from '../config/config';
import { env } from '../env';
import { RegionalToken, RegionalTokenWithValidity } from './redocly-client-types';
import { isNotEmptyObject } from '../utils';
import { colorize } from '../logger';
import type { AccessTokens, Region } from '../config/types';
@@ -29,7 +30,9 @@ export class RedoclyClient {
loadRegion(region?: Region) {
if (region && !DOMAINS[region]) {
throw new Error(
`Invalid argument: region in config file.\nGiven: ${green(region)}, choices: "us", "eu".`
`Invalid argument: region in config file.\nGiven: ${colorize.green(
region
)}, choices: "us", "eu".`
);
}

View File

@@ -6,8 +6,8 @@ import * as pluralize from 'pluralize';
import { parseYaml } from './js-yaml';
import { UserContext } from './walk';
import { HttpResolveConfig } from './config';
import { env } from './config';
import { green, yellow } from 'colorette';
import { env } from './env';
import { logger, colorize } from './logger';
export { parseYaml, stringifyYaml } from './js-yaml';
@@ -207,8 +207,8 @@ export function doesYamlFileExist(filePath: string): boolean {
}
export function showWarningForDeprecatedField(deprecatedField: string, updatedField: string) {
process.stderr.write(
`The ${yellow(deprecatedField)} field is deprecated. Use ${green(
logger.warn(
`The ${colorize.red(deprecatedField)} field is deprecated. Use ${colorize.green(
updatedField
)} instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties\n`
);
@@ -223,3 +223,7 @@ export type Falsy = undefined | null | false | '' | 0;
export function isTruthy<Truthy>(value: Truthy | Falsy): value is Truthy {
return !!value;
}
export function identity<T>(value: T): T {
return value;
}