Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
90c9f7f
feat: adds functionality to lint specs with a user uploaded spectral …
fiosman May 4, 2026
837dc49
Merge branch 'develop' into feat/custom-lint-rules
fiosman May 4, 2026
8b96e61
chore: update var name
fiosman May 4, 2026
7092d46
chore: remove log
fiosman May 4, 2026
f307905
feat: adds logic to persist rulesetFilePath
fiosman May 4, 2026
f327dea
feat: adds logic to remove a uploaded ruleset file and use default OA…
fiosman May 4, 2026
de66df4
feat: adds logic to clean up old ruleset file watcher
fiosman May 5, 2026
0f035c5
chore: adds comment for testing
fiosman May 5, 2026
a879379
chore: adds comment for clarity
fiosman May 5, 2026
4146159
feat: adds logic to enable cunstom lint rules for cloud/git sync proj…
fiosman May 6, 2026
224f18d
Merge branch 'develop' into feat/custom-lint-rules
fiosman May 6, 2026
1a83bf9
chore: remove file watcher for now
fiosman May 7, 2026
53673f1
chore: remove file watcher for now
fiosman May 7, 2026
2f14bc7
feat: adds proper logic to handle syncing rulesets for git sync/cloud…
fiosman May 8, 2026
ac53b4e
chore: remove unneeded event
fiosman May 8, 2026
c0ffa96
feat: auto open up the lint pane if there are lint warnings/errors
fiosman May 8, 2026
29074db
chore: update ApiSpec mutations to include rulesetContent as optional…
fiosman May 8, 2026
eb477e1
feat: uses clientAction mutation hook to update db
fiosman May 8, 2026
6643e9f
chore: adds logic to handle file not found
fiosman May 8, 2026
fd9aad1
feat: removes .spectral.yaml file name restriction
fiosman May 11, 2026
99f6758
chore: remove comment
fiosman May 12, 2026
e27c477
feat: adds some utils to validate user provided spectral ruleset file
fiosman May 13, 2026
13990f9
feat: adds view ruleset modal; slight refactoring to clean up code
fiosman May 13, 2026
d4dbc2a
Merge branch 'develop' into feat/custom-lint-rules
fiosman May 13, 2026
af4f28a
feat: fixes some styling
fiosman May 13, 2026
d5fb410
chore: adds some comments
fiosman May 13, 2026
bbe15a0
chore: change function names/clean up
fiosman May 14, 2026
7b14684
feat: adds logic to flatten extended rulesets into inline prior to wr…
fiosman May 14, 2026
fe7d725
chore: update comment
fiosman May 14, 2026
90ae428
chore: clean up
fiosman May 14, 2026
55c58fb
chore: update comment
fiosman May 14, 2026
19430ea
chore: more comments
fiosman May 14, 2026
5a4f636
chore: remove comment
fiosman May 14, 2026
7bc5dc6
refactor: clean up code to make it more readable
fiosman May 14, 2026
d215692
feat: address double writes when uploading
fiosman May 14, 2026
b8f3c81
chore: update comments/error messages
fiosman May 14, 2026
c832d4b
chore: update comment
fiosman May 14, 2026
3df5ca5
test: adds unit tests for spectral ruleset validator
fiosman May 14, 2026
f12b146
test: adds unit tests for spectral ruleset validator
fiosman May 14, 2026
03c96b0
test: add unit tests
fiosman May 15, 2026
c79f465
chore: cleanup code
fiosman May 15, 2026
ab64fba
chore: make comments much clearer
fiosman May 15, 2026
b8a97cf
feat: adds proper error messages when user attempts to upload a rule …
fiosman May 15, 2026
199fc11
feat: adds UI tweaks
fiosman May 15, 2026
7b02375
test: adds unit tests
fiosman May 15, 2026
f703d99
feat: adds proper logic to handle scenarios when file does not exist …
fiosman May 16, 2026
0c5e30b
feat: adds proper logic to handle scenarios when file does not exist …
fiosman May 16, 2026
ed4548e
chore: sight clean up
fiosman May 19, 2026
a343f60
chore: bring back old code
fiosman May 19, 2026
44b663c
feat: adds logic to migrate rulesets when changing project types
fiosman May 19, 2026
b119e5a
chore: update some styles
fiosman May 20, 2026
9e3f07d
chore: adds more styling changes
fiosman May 20, 2026
615a6b2
chore: more styling updates
fiosman May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions packages/insomnia-inso/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,11 @@ export const go = (args?: string[]) => {
)
.command('spec [identifier]')
.description('Lint an API Specification, identifier can be an API Spec id or a file path')
.action(async identifier => {
.option(
'-r, --ruleset <path>',
'path to a Spectral ruleset file, overrides default OAS ruleset and any ruleset in the API Spec folder',
)
.action(async (identifier, cmd: { ruleset?: string }) => {
const options = await mergeOptionsAndInit({});

// Assert identifier is a file
Expand All @@ -899,11 +903,16 @@ export const go = (args?: string[]) => {
const pathToSearch = '';
let specContent: string | undefined;
let rulesetFileName: string | undefined;
if (cmd.ruleset) {
rulesetFileName = getAbsoluteFilePath({ workingDir: options.workingDir, file: cmd.ruleset });
}
if (isIdentifierAFile) {
// try load as a file
logger.trace(`Linting specification file from identifier: \`${identifierAsAbsPath}\``);
specContent = await fs.promises.readFile(identifierAsAbsPath, 'utf8');
rulesetFileName = await getRuleSetFileFromFolderByFilename(identifierAsAbsPath);
if (!rulesetFileName) {
rulesetFileName = await getRuleSetFileFromFolderByFilename(identifierAsAbsPath);
}
if (!specContent) {
logger.fatal(`Specification content not found using path: ${identifier} in ${identifierAsAbsPath}`);
return process.exit(1);
Expand Down
11 changes: 10 additions & 1 deletion packages/insomnia-inso/src/commands/lint-specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import path from 'node:path';

import { oas } from '@stoplight/spectral-rulesets';
import { DiagnosticSeverity } from '@stoplight/types';
import { safeRefResolver } from 'insomnia/src/common/safe-ref-resolver';
import { validateSpectralRuleset } from 'insomnia/src/common/spectral-ruleset-validator';

import { InsoError } from '../errors';
import { logger } from '../logger';
Expand All @@ -31,11 +33,17 @@ export async function lintSpecification({
specContent: string;
rulesetFileName?: string;
}) {
const spectral = new Spectral();
const spectral = new Spectral({ resolver: safeRefResolver });
// Use custom ruleset if present
let ruleset = oas;
try {
if (rulesetFileName) {
const rulesetContent = await fs.promises.readFile(rulesetFileName, 'utf8');
const validation = validateSpectralRuleset(rulesetContent);
if (!validation.isValid) {
logger.fatal(`Invalid Spectral ruleset: ${validation.error}`);
return { isValid: false };
}
ruleset = await bundleAndLoadRuleset(rulesetFileName, { fs });
}
} catch (error) {
Expand All @@ -45,6 +53,7 @@ export async function lintSpecification({

spectral.setRuleset(ruleset as RulesetDefinition);
const results = await spectral.run(specContent);

if (!results.length) {
logger.log('No linting errors or warnings.');
return { results, isValid: true };
Expand Down
2 changes: 2 additions & 0 deletions packages/insomnia/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@sentry/electron": "^6.5.0",
"@stoplight/spectral-core": "^1.22.0",
"@stoplight/spectral-formats": "^1.8.2",
"@stoplight/spectral-ref-resolver": "^1.0.5",
"@stoplight/spectral-ruleset-bundler": "1.7.0",
"@stoplight/spectral-rulesets": "^1.22.1",
"@tailwindcss/typography": "^0.5.16",
Expand Down Expand Up @@ -113,6 +114,7 @@
"https-proxy-agent": "^7.0.5",
"httpsnippet": "^3.0.10",
"iconv-lite": "^0.6.3",
"ipaddr.js": "^1.9.1",
"isbot": "^5",
"isomorphic-git": "1.25.7",
"js-yaml": "^4.1.0",
Expand Down
240 changes: 240 additions & 0 deletions packages/insomnia/src/common/__tests__/safe-ref-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { safeRefResolver } from '../safe-ref-resolver';

function getHttpResolver() {
return (safeRefResolver as any).resolvers.http;
}

describe('safeHttpResolver', () => {
const httpResolver = getHttpResolver();

beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('URL validation', () => {
it('rejects invalid URLs', async () => {
await expect(
httpResolver.resolve({
href: () => 'not-a-url',
}),
).rejects.toThrow('Failed to resolve $ref "not-a-url"');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects relative URLs', async () => {
await expect(
httpResolver.resolve({
href: () => '/foo/bar.yaml',
}),
).rejects.toThrow('Failed to resolve $ref "/foo/bar.yaml"');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects http URLs', async () => {
await expect(
httpResolver.resolve({
href: () => 'http://example.com/schema.yaml',
}),
).rejects.toThrow('only https URLs to public hosts are allowed');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects ftp URLs', async () => {
await expect(
httpResolver.resolve({
href: () => 'ftp://example.com/schema.yaml',
}),
).rejects.toThrow('only https URLs to public hosts are allowed');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects localhost', async () => {
await expect(
httpResolver.resolve({
href: () => 'https://localhost/schema.yaml',
}),
).rejects.toThrow('only https URLs to public hosts are allowed');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects loopback IPv4 addresses', async () => {
await expect(
httpResolver.resolve({
href: () => 'https://127.0.0.1/schema.yaml',
}),
).rejects.toThrow('only https URLs to public hosts are allowed');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects private IPv4 addresses', async () => {
const urls = [
'https://10.0.0.1/schema.yaml',
'https://172.16.0.1/schema.yaml',
'https://192.168.1.1/schema.yaml',
];

for (const url of urls) {
await expect(
httpResolver.resolve({
href: () => url,
}),
).rejects.toThrow('only https URLs to public hosts are allowed');
}

expect(fetch).not.toHaveBeenCalled();
});

it('rejects link-local IP addresses', async () => {
await expect(
httpResolver.resolve({
href: () => 'https://169.254.169.254/latest/meta-data',
}),
).rejects.toThrow('only https URLs to public hosts are allowed');

expect(fetch).not.toHaveBeenCalled();
});

it('rejects IPv6 loopback addresses', async () => {
await expect(
httpResolver.resolve({
href: () => 'https://[::1]/schema.yaml',
}),
).rejects.toThrow('only https URLs to public hosts are allowed');

expect(fetch).not.toHaveBeenCalled();
});

it('allows public HTTPS URLs', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
text: vi.fn().mockResolvedValue('openapi: 3.1.0'),
} as unknown as Response);

const result = await httpResolver.resolve({
href: () => 'https://example.com/schema.yaml',
});

expect(result).toBe('openapi: 3.1.0');

expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://example.com/schema.yaml');
});

it('allows HTTPS URLs with ports', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
text: vi.fn().mockResolvedValue('ok'),
} as unknown as Response);

await expect(
httpResolver.resolve({
href: () => 'https://example.com:8443/schema.yaml',
}),
).resolves.toBe('ok');

expect(fetch).toHaveBeenCalledOnce();
});

it('allows HTTPS URLs with query strings', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
text: vi.fn().mockResolvedValue('ok'),
} as unknown as Response);

await expect(
httpResolver.resolve({
href: () => 'https://example.com/schema.yaml?raw=1',
}),
).resolves.toBe('ok');

expect(fetch).toHaveBeenCalledOnce();
});
});

describe('fetch handling', () => {
it('returns response text for successful fetches', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
text: vi.fn().mockResolvedValue('test-content'),
} as unknown as Response);

await expect(
httpResolver.resolve({
href: () => 'https://example.com/test.yaml',
}),
).resolves.toBe('test-content');
});

it('throws on 404 responses', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
} as unknown as Response);

await expect(
httpResolver.resolve({
href: () => 'https://example.com/missing.yaml',
}),
).rejects.toThrow('Failed to fetch $ref "https://example.com/missing.yaml": 404 Not Found');
});

it('throws on 500 responses', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as unknown as Response);

await expect(
httpResolver.resolve({
href: () => 'https://example.com/error.yaml',
}),
).rejects.toThrow('Failed to fetch $ref "https://example.com/error.yaml": 500 Internal Server Error');
});

it('propagates fetch network errors', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('network failure'));

await expect(
httpResolver.resolve({
href: () => 'https://example.com/schema.yaml',
}),
).rejects.toThrow('network failure');
});

it('propagates response.text() failures', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
text: vi.fn().mockRejectedValue(new Error('failed reading body')),
} as unknown as Response);

await expect(
httpResolver.resolve({
href: () => 'https://example.com/schema.yaml',
}),
).rejects.toThrow('failed reading body');
});
});

describe('resolver wiring', () => {
it('uses the same resolver for http and https keys', () => {
// @ts-expect-error internal access for test verification
const resolvers = safeRefResolver.resolvers;

expect(resolvers.http).toBe(resolvers.https);
});
});
});
Loading
Loading