diff --git a/.changeset/sour-wings-follow.md b/.changeset/sour-wings-follow.md new file mode 100644 index 0000000000..e4019037a0 --- /dev/null +++ b/.changeset/sour-wings-follow.md @@ -0,0 +1,5 @@ +--- +"@redocly/openapi-core": patch +--- + +Fixed an issue where `.redocly.lint-ignore.yaml` was not loaded in browser environments. diff --git a/packages/core/src/__tests__/lint.test.ts b/packages/core/src/__tests__/lint.test.ts index 745e9d737b..ce083e84b6 100644 --- a/packages/core/src/__tests__/lint.test.ts +++ b/packages/core/src/__tests__/lint.test.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import { outdent } from 'outdent'; import { lintFromString, lintConfig, lintDocument, lint } from '../lint.js'; import { BaseResolver } from '../resolve.js'; -import { createConfig, loadConfig } from '../config/load.js'; +import { createConfig, loadConfig, loadIgnoreFile } from '../config/load.js'; import { parseYamlToDocument, replaceSourceWithRef } from '../../__tests__/utils.js'; import { detectSpec } from '../detect-spec.js'; import { @@ -1712,15 +1712,20 @@ describe('lint', () => { ); const configFilePath = path.join(__dirname, 'fixtures'); + const resolver = new BaseResolver(); + const ignoreResult = await loadIgnoreFile(configFilePath, resolver); const result = await lintDocument({ - externalRefResolver: new BaseResolver(), + externalRefResolver: resolver, document, config: await createConfig( { rules: { 'operation-operationId': 'error' }, }, - { configPath: configFilePath } + { + configPath: configFilePath, + ignoreFile: ignoreResult, + } ), }); expect(result).toHaveLength(1); diff --git a/packages/core/src/__tests__/ref-utils.test.ts b/packages/core/src/__tests__/ref-utils.test.ts index 78450d8abb..d2df03f245 100644 --- a/packages/core/src/__tests__/ref-utils.test.ts +++ b/packages/core/src/__tests__/ref-utils.test.ts @@ -5,6 +5,9 @@ import { parseRef, refBaseName, unescapePointerFragment, + isAbsoluteUrl, + getDir, + resolvePath, } from '../ref-utils.js'; import { lintDocument } from '../lint.js'; import { createConfig } from '../config/index.js'; @@ -183,4 +186,50 @@ describe('ref-utils', () => { expect(unescapePointerFragment('scope~1complex~0name')).toStrictEqual('scope/complex~name'); }); }); + + describe('isAbsoluteUrl', () => { + it('should return true for http://, https://, and file:// URLs', () => { + expect(isAbsoluteUrl('http://example.com/api.yaml')).toBe(true); + expect(isAbsoluteUrl('https://example.com/api.yaml')).toBe(true); + expect(isAbsoluteUrl('file:///Users/test/api.yaml')).toBe(true); + }); + + it('should return false for relative and absolute file paths', () => { + expect(isAbsoluteUrl('./api.yaml')).toBe(false); + expect(isAbsoluteUrl('../api.yaml')).toBe(false); + expect(isAbsoluteUrl('/Users/test/api.yaml')).toBe(false); + }); + }); + + describe('getDir', () => { + it('should return directory for file paths and URLs', () => { + expect(getDir('/Users/test/config/redocly.yaml')).toBe('/Users/test/config'); + expect(getDir('http://example.com/config/redocly.yaml')).toBe('http://example.com/config'); + expect(getDir('https://example.com/config/redocly.yaml')).toBe('https://example.com/config'); + expect(getDir('file:///Users/test/config/redocly.yaml')).toBe('file:///Users/test/config'); + }); + + it('should return path as-is if no extension (directory)', () => { + expect(getDir('/Users/test/config')).toBe('/Users/test/config'); + expect(getDir('file:///Users/test/config')).toBe('file:///Users/test/config'); + }); + }); + + describe('resolvePath', () => { + it('should resolve paths for URLs', () => { + expect(resolvePath('http://example.com/config', 'file.yaml')).toBe( + 'http://example.com/config/file.yaml' + ); + expect(resolvePath('https://example.com/config/', 'file.yaml')).toBe( + 'https://example.com/config/file.yaml' + ); + expect(resolvePath('file:///Users/test/config', 'file.yaml')).toBe( + 'file:///Users/test/config/file.yaml' + ); + }); + + it('should resolve relative paths for file system paths', () => { + expect(resolvePath('/Users/test/config', 'file.yaml')).toMatch(/file\.yaml$/); + }); + }); }); 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/api.yaml b/packages/core/src/config/__tests__/fixtures/ignore-file/api.yaml new file mode 100644 index 0000000000..84de4bd0ae --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/ignore-file/api.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /pets: + get: + operationId: '' + summary: Get pets + responses: + '200': + description: OK 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..585fb0359f --- /dev/null +++ b/packages/core/src/config/__tests__/fixtures/ignore-file/redocly.yaml @@ -0,0 +1,3 @@ +rules: + operation-operationId: error + operation-summary: 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..d2441acf08 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -1344,3 +1344,34 @@ 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 ignore only rules specified in ignore file', async () => { + const config = await loadConfig({ configPath: ignoreFileConfig }); + + expect(Object.keys(config.ignore)).toEqual([expectedIgnoreKey]); + expect(config.ignore[expectedIgnoreKey]['operation-operationId']).toBeInstanceOf(Set); + expect(config.ignore[expectedIgnoreKey]['operation-summary']).toBeUndefined(); + }); + + 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..df153881fd 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1,13 +1,11 @@ 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 { isAbsoluteUrl, resolvePath } from '../ref-utils.js'; import { groupAssertionRules } from './group-assertion-rules.js'; import { IGNORE_BANNER, IGNORE_FILE } from './constants.js'; @@ -33,18 +31,10 @@ import type { ResolvedConfig, RuleConfig, RuleSettings, + IgnoreFile, + ResolvedIgnore, } 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; @@ -54,7 +44,7 @@ export class Config { _alias?: string; plugins: Plugin[]; - ignore: Record>> = {}; + ignore: ResolvedIgnore = {}; doNotResolveExamples: boolean; rules: Record>; preprocessors: Record>; @@ -71,6 +61,8 @@ export class Config { resolvedRefMap?: ResolvedRefMap; alias?: string; plugins?: Plugin[]; + ignoreFile?: IgnoreFile; + ignore?: ResolvedIgnore; } = {} ) { this.resolvedConfig = resolvedConfig; @@ -153,7 +145,25 @@ export class Config { }, }; - this.resolveIgnore(getIgnoreFilePath(opts.configPath)); + this.ignore = opts.ignore ?? (opts.ignoreFile ? this.resolveIgnore(opts.ignoreFile) : {}); + } + + private resolveIgnore({ content, dir }: IgnoreFile): ResolvedIgnore { + const ignore: ResolvedIgnore = Object.create(null); + + for (const fileName of Object.keys(content)) { + const fileIgnore = content[fileName]; + + const resolvedFileName = isAbsoluteUrl(fileName) ? fileName : resolvePath(dir, fileName); + + ignore[resolvedFileName] = Object.create(null); + + for (const ruleId of Object.keys(fileIgnore)) { + ignore[resolvedFileName][ruleId] = new Set(fileIgnore[ruleId]); + } + } + + return ignore; } forAlias(alias?: string) { @@ -171,35 +181,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..f624135124 100644 --- a/packages/core/src/config/load.ts +++ b/packages/core/src/config/load.ts @@ -8,9 +8,37 @@ 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 { isAbsoluteUrl, getDir, resolvePath } from '../ref-utils.js'; +import { isBrowser } from '../env.js'; -import type { RawUniversalConfig } from './types.js'; +import type { RawUniversalConfig, IgnoreFile } from './types.js'; + +export async function loadIgnoreFile( + configPath: string | undefined, + resolver: BaseResolver +): Promise { + const configDir = configPath ? getDir(configPath) : isBrowser ? '' : process.cwd(); + const ignorePath = configDir ? resolvePath(configDir, IGNORE_FILE) : IGNORE_FILE; + if (fs?.existsSync && !isAbsoluteUrl(ignorePath) && !fs.existsSync(ignorePath)) { + return undefined; + } + + const ignoreDocument = await resolver.resolveDocument( + null, + ignorePath, + true + ); + + if (ignoreDocument instanceof Error || !ignoreDocument.parsed) { + return undefined; + } + + return { + content: ignoreDocument.parsed || {}, + dir: configDir, + }; +} export async function loadConfig( options: { @@ -38,11 +66,14 @@ export async function loadConfig( externalRefResolver, }); + const ignoreFile = await loadIgnoreFile(configPath, resolver); + const config = new Config(resolvedConfig, { configPath, document: rawConfigDocument, resolvedRefMap: resolvedRefMap, plugins, + ignoreFile, }); return config; @@ -58,11 +89,12 @@ type CreateConfigOptions = { configPath?: string; externalRefResolver?: BaseResolver; resolvedRefMap?: ResolvedRefMap; + ignoreFile?: IgnoreFile; }; export async function createConfig( config?: string | RawUniversalConfig, - { configPath, externalRefResolver }: CreateConfigOptions = {} + { configPath, externalRefResolver, ignoreFile }: CreateConfigOptions = {} ): Promise { const rawConfigSource = typeof config === 'string' ? config : ''; const rawConfigDocument = makeDocumentFromString( @@ -79,11 +111,13 @@ export async function createConfig( configPath, externalRefResolver, }); + return new Config(resolvedConfig, { configPath, document: rawConfigDocument, resolvedRefMap, plugins, + ignoreFile, }); } diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 685228a050..a817a08ace 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -263,3 +263,6 @@ export type ResolvedConfig = Omit & apis?: Record; plugins?: string[]; }; + +export type ResolvedIgnore = Record>>; +export type IgnoreFile = { content: Record>; dir: string }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3033accd55..f875a0d232 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export { normalizeTypes, type NormalizedNodeType, type NodeType } from './types/ export { Stats } from './rules/other/stats.js'; export { loadConfig, + loadIgnoreFile, createConfig, findConfig, resolvePlugins, @@ -55,6 +56,8 @@ export { unescapePointerFragment, isRef, isAbsoluteUrl, + getDir, + resolvePath, escapePointerFragment, type Location, } from './ref-utils.js'; diff --git a/packages/core/src/ref-utils.ts b/packages/core/src/ref-utils.ts index 235a6cfd6d..54ac3cb5c0 100644 --- a/packages/core/src/ref-utils.ts +++ b/packages/core/src/ref-utils.ts @@ -1,3 +1,4 @@ +import * as path from 'node:path'; import { isTruthy } from './utils/is-truthy.js'; import { isPlainObject } from './utils/is-plain-object.js'; @@ -82,7 +83,24 @@ export function refBaseName(ref: string) { } export function isAbsoluteUrl(ref: string) { - return ref.startsWith('http://') || ref.startsWith('https://'); + return ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('file://'); +} + +export function getDir(filePath: string): string { + if (!path.extname(filePath)) { + return filePath; + } + + return isAbsoluteUrl(filePath) + ? filePath.substring(0, filePath.lastIndexOf('/')) + : path.dirname(filePath); +} + +export function resolvePath(base: string, relative: string): string { + if (isAbsoluteUrl(base)) { + return new URL(relative, base.endsWith('/') ? base : `${base}/`).href; + } + return path.resolve(base, relative); } export function isMappingRef(mapping: string) { diff --git a/tests/smoke/basic/pre-built/redoc.html b/tests/smoke/basic/pre-built/redoc.html index 5675a287c8..0e4eba2bfd 100644 --- a/tests/smoke/basic/pre-built/redoc.html +++ b/tests/smoke/basic/pre-built/redoc.html @@ -12,284 +12,13 @@ margin: 0; } - + -

Sample API (1.0.0)

Download OpenAPI specification:

Get a greeting message

Responses

Response samples

Content type
application/json
{
  • "message": "string"
}
+ " fill="currentColor">

Sample API (1.0.0)

Download OpenAPI specification:

Get a greeting message

Responses

Response samples

Content type
application/json
{
  • "message": "string"
}