Skip to content

Commit 7abbe95

Browse files
committed
feat(ruleset-migrator): use Content-Type header to detect ruleset format (stoplightio#2317)
1 parent 6255f8d commit 7abbe95

File tree

5 files changed

+132
-6
lines changed

5 files changed

+132
-6
lines changed

packages/ruleset-migrator/src/__tests__/ruleset.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ afterAll(() => {
1818
vol.reset();
1919
});
2020

21+
function createFetchMockSandbox() {
22+
// something is off with default module interop in Karma :man_shrugging:
23+
return ((fetchMock as { default?: typeof import('fetch-mock') }).default ?? fetchMock).sandbox();
24+
}
25+
2126
const scenarios = Object.keys(fixtures)
2227
.filter(key => path.basename(key) === 'output.mjs')
2328
.map(key => path.dirname(key));
@@ -99,8 +104,7 @@ describe('migrator', () => {
99104
});
100105

101106
it('should accept custom fetch implementation', async () => {
102-
// something is off with default module interop in Karma :man_shrugging:
103-
const fetch = ((fetchMock as { default?: typeof import('fetch-mock') }).default ?? fetchMock).sandbox();
107+
const fetch = createFetchMockSandbox();
104108

105109
await vol.promises.writeFile(
106110
path.join(cwd, 'ruleset.json'),
@@ -123,6 +127,9 @@ describe('migrator', () => {
123127
},
124128
},
125129
},
130+
headers: {
131+
'Content-Type': 'application/json; charset=utf-8',
132+
},
126133
});
127134

128135
expect(
@@ -218,6 +225,36 @@ export default {
218225
`);
219226
});
220227

228+
it('should use Content-Type detection', async () => {
229+
const fetch = createFetchMockSandbox();
230+
231+
await vol.promises.writeFile(
232+
path.join(cwd, 'ruleset.json'),
233+
JSON.stringify({
234+
extends: ['https://spectral.stoplight.io/ruleset'],
235+
}),
236+
);
237+
238+
fetch.get('https://spectral.stoplight.io/ruleset', {
239+
body: `export default { rules: {} }`,
240+
headers: {
241+
'Content-Type': 'application/javascript; charset=utf-8',
242+
},
243+
});
244+
245+
expect(
246+
await migrateRuleset(path.join(cwd, 'ruleset.json'), {
247+
format: 'esm',
248+
fs: vol as any,
249+
fetch,
250+
}),
251+
).toEqual(`import ruleset_ from "https://spectral.stoplight.io/ruleset";
252+
export default {
253+
"extends": [ruleset_]
254+
};
255+
`);
256+
});
257+
221258
describe('custom npm registry', () => {
222259
it('should be supported', async () => {
223260
serveAssets({

packages/ruleset-migrator/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { ExpressionKind } from 'ast-types/gen/kinds';
1010
import { assertRuleset } from './validation';
1111
import { Ruleset } from './validation/types';
1212

13+
export { isBasicRuleset } from './utils/isBasicRuleset';
14+
1315
async function read(filepath: string, fs: MigrationOptions['fs'], fetch: Fetch): Promise<Ruleset> {
1416
const input = isURL(filepath) ? await (await fetch(filepath)).text() : await fs.promises.readFile(filepath, 'utf8');
1517

packages/ruleset-migrator/src/transformers/extends.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ import * as path from '@stoplight/path';
33
import { Transformer, TransformerCtx } from '../types';
44
import { Ruleset } from '../validation/types';
55
import { assertArray } from '../validation';
6-
import { process } from '..';
6+
import { process } from '../index';
7+
import { isBasicRuleset } from '../utils/isBasicRuleset';
78

89
const REPLACEMENTS = {
910
'spectral:oas': 'oas',
1011
'spectral:asyncapi': 'asyncapi',
1112
};
1213

13-
const KNOWN_JS_EXTS = /^\.[cm]?js$/;
14-
1514
export { transformer as default };
1615

1716
async function processExtend(
@@ -24,7 +23,7 @@ async function processExtend(
2423

2524
const filepath = ctx.tree.resolveModule(name, ctx, 'ruleset');
2625

27-
if (KNOWN_JS_EXTS.test(path.extname(filepath))) {
26+
if (!(await isBasicRuleset(filepath, ctx.opts.fetch))) {
2827
return ctx.tree.addImport(`${path.basename(filepath, true)}_${path.extname(filepath)}`, filepath, true);
2928
}
3029

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { isBasicRuleset } from '../isBasicRuleset';
2+
3+
describe('isBasicRuleset util', () => {
4+
it.concurrent.each(['json', 'yaml', 'yml'])('given %s extension, should return true', async ext => {
5+
const fetch = jest.fn();
6+
await expect(isBasicRuleset(`/ruleset.${ext}`, fetch)).resolves.toBe(true);
7+
expect(fetch).not.toBeCalled();
8+
});
9+
10+
it.concurrent.each(['js', 'mjs', 'cjs'])('given %s extension, should return false', async ext => {
11+
const fetch = jest.fn();
12+
await expect(isBasicRuleset(`/ruleset.${ext}`, fetch)).resolves.toBe(false);
13+
expect(fetch).not.toBeCalled();
14+
});
15+
16+
it.concurrent('given an URL with query, should strip query prior to the lookup', async () => {
17+
const fetch = jest.fn();
18+
await expect(isBasicRuleset(`https://stoplight.io/ruleset.yaml?token=test`, fetch)).resolves.toBe(true);
19+
expect(fetch).not.toBeCalled();
20+
});
21+
22+
it.concurrent.each([
23+
'application/json',
24+
'application/yaml',
25+
'text/json',
26+
'text/yaml',
27+
'application/yaml; charset=utf-8',
28+
'application/json; charset=utf-8',
29+
'text/yaml; charset=utf-16',
30+
])('given %s Content-Type, should return true', async input => {
31+
const fetch = jest.fn().mockResolvedValue({
32+
headers: new Map([['Content-Type', input]]),
33+
});
34+
35+
await expect(isBasicRuleset('https://stoplight.io', fetch)).resolves.toBe(true);
36+
});
37+
38+
it.concurrent.each(['application/javascript', 'application/x-yaml', 'application/yaml-', 'something/yaml'])(
39+
'given %s Content-Type, should return false',
40+
async input => {
41+
const fetch = jest.fn().mockResolvedValue({
42+
headers: new Map([['Content-Type', input]]),
43+
});
44+
45+
await expect(isBasicRuleset('https://stoplight.io', fetch)).resolves.toBe(false);
46+
},
47+
);
48+
49+
it.concurrent('given fetch failure, should return false', async () => {
50+
const fetch = jest.fn().mockRejectedValueOnce(new Error());
51+
52+
await expect(isBasicRuleset('https://stoplight.io', fetch)).resolves.toBe(false);
53+
});
54+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { fetch as defaultFetch } from '@stoplight/spectral-runtime';
2+
import { isURL, extname } from '@stoplight/path';
3+
import type { Fetch } from '../types';
4+
5+
function stripSearchFromUrl(url: string): string {
6+
try {
7+
const { href, search } = new URL(url);
8+
return href.slice(0, href.length - search.length);
9+
} catch {
10+
return url;
11+
}
12+
}
13+
14+
const CONTENT_TYPE_REGEXP = /^(?:application|text)\/(?:yaml|json)(?:;|$)/i;
15+
const EXT_REGEXP = /\.(json|ya?ml)$/i;
16+
17+
export async function isBasicRuleset(uri: string, fetch: Fetch = defaultFetch): Promise<boolean> {
18+
const ext = extname(isURL(uri) ? stripSearchFromUrl(uri) : uri);
19+
20+
if (EXT_REGEXP.test(ext)) {
21+
return true;
22+
}
23+
24+
if (!isURL(uri)) {
25+
return false;
26+
}
27+
28+
try {
29+
const contentType = (await fetch(uri)).headers.get('Content-Type');
30+
return contentType !== null && CONTENT_TYPE_REGEXP.test(contentType);
31+
} catch {
32+
return false;
33+
}
34+
}

0 commit comments

Comments
 (0)