diff --git a/.changeset/sour-wings-follow.md b/.changeset/sour-wings-follow.md new file mode 100644 index 0000000000..803f198d35 --- /dev/null +++ b/.changeset/sour-wings-follow.md @@ -0,0 +1,5 @@ +--- +"@redocly/openapi-core": patch +--- + +Fixed loading of `.redocly.lint-ignore.yaml` in browser environments. diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts index 5aa93cca3e..4e4f185ea1 100644 --- a/packages/core/src/config/__tests__/config.test.ts +++ b/packages/core/src/config/__tests__/config.test.ts @@ -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( @@ -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(); diff --git a/packages/core/src/config/__tests__/fixtures/ignore-file/.redocly.lint-ignore.yaml b/packages/core/src/config/__tests__/fixtures/ignore-file/.redocly.lint-ignore.yaml new file mode 100644 index 0000000000..731e0688bb --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/ignore-file/.redocly.lint-ignore.yaml @@ -0,0 +1,3 @@ +api.yaml: + operation-operationId: + - '#/paths/~1pets/get/operationId' diff --git a/packages/core/src/config/__tests__/fixtures/ignore-file/redocly.yaml b/packages/core/src/config/__tests__/fixtures/ignore-file/redocly.yaml new file mode 100644 index 0000000000..9c87dce795 --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/ignore-file/redocly.yaml @@ -0,0 +1,2 @@ +rules: + operation-operationId: error diff --git a/packages/core/src/config/__tests__/fixtures/ingore-file.ts b/packages/core/src/config/__tests__/fixtures/ingore-file.ts deleted file mode 100644 index fbb543a1ab..0000000000 --- a/packages/core/src/config/__tests__/fixtures/ingore-file.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const ignoredFileStub = { - 'openapi.yaml': { - 'no-unused-components': ['#/components/schemas/Foo'], - }, - 'https://some-path.yaml': { - 'no-unused-components': ['#/components/schemas/Foo'], - }, -}; diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index a6a39183ac..1c720e71f5 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b99170eb2a..b74cb23fa8 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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'; @@ -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; @@ -71,6 +59,7 @@ export class Config { resolvedRefMap?: ResolvedRefMap; alias?: string; plugins?: Plugin[]; + ignore?: Record>>; } = {} ) { this.resolvedConfig = resolvedConfig; @@ -153,7 +142,7 @@ export class Config { }, }; - this.resolveIgnore(getIgnoreFilePath(opts.configPath)); + this.ignore = opts.ignore || {}; } forAlias(alias?: string) { @@ -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> - >) || {}; - - // 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); diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts index 02bf9eb685..593835ffb7 100644 --- a/packages/core/src/config/load.ts +++ b/packages/core/src/config/load.ts @@ -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>> | 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>>; + + 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; @@ -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; @@ -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, }); }