Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/sour-wings-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@redocly/openapi-core": patch
---

Fixed loading of `.redocly.lint-ignore.yaml` in browser environments.
32 changes: 9 additions & 23 deletions packages/core/src/config/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,6 @@
import { type SpecVersion } from '../../oas-types.js';
import { Config } from '../config.js';
import * as jsYaml from '../../js-yaml/index.js';
import * as fs from 'node:fs';
import { ignoredFileStub } from './fixtures/ingore-file.js';
import * as path from 'node:path';
import { createConfig } from '../index.js';
import * as doesYamlFileExistModule from '../../utils/does-yaml-file-exist.js';

vi.mock('../../js-yaml/index.js', async () => {
const actual = await vi.importActual('../../js-yaml/index.js');
return { ...actual };
});
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
return { ...actual };
});
vi.mock('node:path', async () => {
const actual = await vi.importActual('node:path');
return { ...actual };
});

// Create the config and clean up not needed props for consistency
const testConfig: Config = await createConfig(
Expand Down Expand Up @@ -237,12 +219,16 @@ describe('Config.extendTypes', () => {

describe('generation ignore object', () => {
it('should generate config with absoluteUri for ignore', () => {
vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => '');
vi.spyOn(jsYaml, 'parseYaml').mockImplementationOnce(() => ignoredFileStub);
vi.spyOn(doesYamlFileExistModule, 'doesYamlFileExist').mockImplementationOnce(() => true);
vi.spyOn(path, 'resolve').mockImplementationOnce((_, filename) => `some-path/${filename}`);
const ignore = {
'some-path/openapi.yaml': {
'no-unused-components': new Set(['#/components/schemas/Foo']),
},
'https://some-path.yaml': {
'no-unused-components': new Set(['#/components/schemas/Foo']),
},
};

const config = new Config(testConfig.resolvedConfig);
const config = new Config(testConfig.resolvedConfig, { ignore });
config.resolvedConfig = 'resolvedConfig stub' as any;

expect(config).toMatchSnapshot();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
api.yaml:
operation-operationId:
- '#/paths/~1pets/get/operationId'
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
rules:
operation-operationId: error
8 changes: 0 additions & 8 deletions packages/core/src/config/__tests__/fixtures/ingore-file.ts

This file was deleted.

30 changes: 30 additions & 0 deletions packages/core/src/config/__tests__/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,3 +1344,33 @@ function verifyOasRules(
}
});
}

describe('loadIgnoreFile', () => {
const ignoreFileDir = path.join(__dirname, './fixtures/ignore-file');
const ignoreFileConfig = path.join(ignoreFileDir, 'redocly.yaml');
const expectedIgnoreKey = path.join(ignoreFileDir, 'api.yaml');

it('should load and parse ignore file correctly', async () => {
const config = await loadConfig({ configPath: ignoreFileConfig });

expect(Object.keys(config.ignore)).toEqual([expectedIgnoreKey]);
expect(config.ignore[expectedIgnoreKey]['operation-operationId']).toBeInstanceOf(Set);
});

it('should return empty object when ignore file does not exist', async () => {
const configPath = path.join(__dirname, './fixtures/load-redocly.yaml');
const config = await loadConfig({ configPath });

expect(config.ignore).toEqual({});
});

it('should load ignore file in browser environment (without fs.existsSync)', async () => {
const existsSyncSpy = vi.spyOn(fs, 'existsSync').mockImplementation(undefined as any);

const config = await loadConfig({ configPath: ignoreFileConfig });

expect(Object.keys(config.ignore)).toEqual([expectedIgnoreKey]);

existsSyncSpy.mockRestore();
});
});
43 changes: 4 additions & 39 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parseYaml, stringifyYaml } from '../js-yaml/index.js';
import { stringifyYaml } from '../js-yaml/index.js';
import { slash } from '../utils/slash.js';
import { doesYamlFileExist } from '../utils/does-yaml-file-exist.js';
import { isPlainObject } from '../utils/is-plain-object.js';
import { specVersions } from '../detect-spec.js';
import { isBrowser } from '../env.js';
import { getResolveConfig } from './get-resolve-config.js';
import { isAbsoluteUrl } from '../ref-utils.js';
import { groupAssertionRules } from './group-assertion-rules.js';
Expand Down Expand Up @@ -35,16 +33,6 @@ import type {
RuleSettings,
} from './types.js';

function getIgnoreFilePath(configPath?: string): string | undefined {
if (configPath) {
return doesYamlFileExist(configPath)
? path.join(path.dirname(configPath), IGNORE_FILE)
: path.join(configPath, IGNORE_FILE);
} else {
return isBrowser ? undefined : path.join(process.cwd(), IGNORE_FILE);
}
}

export class Config {
resolvedConfig: ResolvedConfig;
configPath?: string;
Expand All @@ -71,6 +59,7 @@ export class Config {
resolvedRefMap?: ResolvedRefMap;
alias?: string;
plugins?: Plugin[];
ignore?: Record<string, Record<string, Set<string>>>;
} = {}
) {
this.resolvedConfig = resolvedConfig;
Expand Down Expand Up @@ -153,7 +142,7 @@ export class Config {
},
};

this.resolveIgnore(getIgnoreFilePath(opts.configPath));
this.ignore = opts.ignore || {};
}

forAlias(alias?: string) {
Expand All @@ -171,35 +160,11 @@ export class Config {
resolvedRefMap: this.resolvedRefMap,
alias,
plugins: this.plugins,
ignore: this.ignore,
}
);
}

resolveIgnore(ignoreFile?: string) {
if (!ignoreFile || !doesYamlFileExist(ignoreFile)) return;

this.ignore =
(parseYaml(fs.readFileSync(ignoreFile, 'utf-8')) as Record<
string,
Record<string, Set<string>>
>) || {};

// resolve ignore paths
for (const fileName of Object.keys(this.ignore)) {
this.ignore[
isAbsoluteUrl(fileName) ? fileName : path.resolve(path.dirname(ignoreFile), fileName)
] = this.ignore[fileName];

for (const ruleId of Object.keys(this.ignore[fileName])) {
this.ignore[fileName][ruleId] = new Set(this.ignore[fileName][ruleId]);
}

if (!isAbsoluteUrl(fileName)) {
delete this.ignore[fileName];
}
}
}

saveIgnore() {
const dir = this.configPath ? path.dirname(this.configPath) : process.cwd();
const ignoreFile = path.join(dir, IGNORE_FILE);
Expand Down
69 changes: 68 additions & 1 deletion packages/core/src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,69 @@ import {
type Document,
type ResolvedRefMap,
} from '../resolve.js';
import { CONFIG_FILE_NAME } from './constants.js';
import { CONFIG_FILE_NAME, IGNORE_FILE } from './constants.js';

import type { RawUniversalConfig } from './types.js';

function isUrl(ref: string): boolean {
return ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('file://');
}

function resolvePath(base: string, relative: string): string {
if (isUrl(base)) {
return new URL(relative, base.endsWith('/') ? base : `${base}/`).href;
}
return path.resolve(base, relative);
}

function getConfigDir(configPath: string): string {
if (!path.extname(configPath)) {
return configPath;
}

return isUrl(configPath)
? configPath.substring(0, configPath.lastIndexOf('/'))
: path.dirname(configPath);
}

async function loadIgnoreFile(
configPath: string | undefined,
resolver: BaseResolver
): Promise<Record<string, Record<string, Set<string>>> | undefined> {
if (!configPath) return undefined;

const configDir = getConfigDir(configPath);
const ignorePath = resolvePath(configDir, IGNORE_FILE);

if (fs?.existsSync && !isUrl(ignorePath) && !fs.existsSync(ignorePath)) {
return undefined;
}

const ignoreDocument = await resolver.resolveDocument(null, ignorePath, true);

if (ignoreDocument instanceof Error || !ignoreDocument.parsed) {
return undefined;
}

const ignore = (ignoreDocument.parsed || {}) as Record<string, Record<string, Set<string>>>;

for (const fileName of Object.keys(ignore)) {
const resolvedFileName = isUrl(fileName) ? fileName : resolvePath(configDir, fileName);

ignore[resolvedFileName] = ignore[fileName];

for (const ruleId of Object.keys(ignore[fileName])) {
ignore[fileName][ruleId] = new Set(ignore[fileName][ruleId]);
}

if (resolvedFileName !== fileName) {
delete ignore[fileName];
}
}

return ignore;
}

export async function loadConfig(
options: {
configPath?: string;
Expand All @@ -38,11 +97,14 @@ export async function loadConfig(
externalRefResolver,
});

const ignore = await loadIgnoreFile(configPath, resolver);

const config = new Config(resolvedConfig, {
configPath,
document: rawConfigDocument,
resolvedRefMap: resolvedRefMap,
plugins,
ignore,
});

return config;
Expand Down Expand Up @@ -79,11 +141,16 @@ export async function createConfig(
configPath,
externalRefResolver,
});

const resolver = externalRefResolver ?? new BaseResolver();
const ignore = await loadIgnoreFile(configPath, resolver);

return new Config(resolvedConfig, {
configPath,
document: rawConfigDocument,
resolvedRefMap,
plugins,
ignore,
});
}

Expand Down
Loading