Skip to content

Commit b005a85

Browse files
authored
Merge pull request #1668 from hey-api/fix/watch-mode-patch
fix: watch mode tweaks
2 parents d0af19e + 7a03341 commit b005a85

File tree

25 files changed

+1099
-706
lines changed

25 files changed

+1099
-706
lines changed

.changeset/gentle-deers-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hey-api/openapi-ts': patch
3+
---
4+
5+
fix: watch mode handles servers not exposing HEAD method for spec

.changeset/happy-eels-kick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hey-api/openapi-ts': patch
3+
---
4+
5+
fix: add watch.timeout option

docs/openapi-ts/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ Setting `output.clean` to `false` may result in broken output. Ensure you typech
273273

274274
## Config API
275275

276-
You can view the complete list of options in the [UserConfig](https://github.com/hey-api/openapi-ts/blob/main/packages/openapi-ts/src/types/config.ts) interface.
276+
You can view the complete list of options in the [UserConfig](https://github.com/hey-api/openapi-ts/blob/main/packages/openapi-ts/src/types/config.d.ts) interface.
277277

278278
<!--@include: ../examples.md-->
279279
<!--@include: ../sponsors.md-->

packages/openapi-ts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"node": "^18.18.0 || ^20.9.0 || >=22.11.0"
8080
},
8181
"dependencies": {
82-
"@hey-api/json-schema-ref-parser": "1.0.1",
82+
"@hey-api/json-schema-ref-parser": "1.0.2",
8383
"c12": "2.0.1",
8484
"commander": "13.0.0",
8585
"handlebars": "4.7.8"
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import path from 'node:path';
2+
3+
import { generateLegacyOutput, generateOutput } from './generate/output';
4+
import { getSpec } from './getSpec';
5+
import type { IR } from './ir/types';
6+
import { parseLegacy, parseOpenApiSpec } from './openApi';
7+
import { processOutput } from './processOutput';
8+
import type { Client } from './types/client';
9+
import type { Config } from './types/config';
10+
import type { WatchValues } from './types/types';
11+
import { isLegacyClient, legacyNameFromConfig } from './utils/config';
12+
import type { Templates } from './utils/handlebars';
13+
import { Performance } from './utils/performance';
14+
import { postProcessClient } from './utils/postprocess';
15+
16+
export const createClient = async ({
17+
config,
18+
templates,
19+
watch: _watch,
20+
}: {
21+
config: Config;
22+
templates: Templates;
23+
watch?: WatchValues;
24+
}) => {
25+
const inputPath = config.input.path;
26+
const timeout = config.watch.timeout;
27+
28+
const watch: WatchValues = _watch || { headers: new Headers() };
29+
30+
Performance.start('spec');
31+
const { data, error, response } = await getSpec({
32+
inputPath,
33+
timeout,
34+
watch,
35+
});
36+
Performance.end('spec');
37+
38+
// throw on first run if there's an error to preserve user experience
39+
// if in watch mode, subsequent errors won't throw to gracefully handle
40+
// cases where server might be reloading
41+
if (error && !_watch) {
42+
throw new Error(
43+
`Request failed with status ${response.status}: ${response.statusText}`,
44+
);
45+
}
46+
47+
let client: Client | undefined;
48+
let context: IR.Context | undefined;
49+
50+
if (data) {
51+
if (_watch) {
52+
console.clear();
53+
console.log(`⏳ Input changed, generating from ${inputPath}`);
54+
} else {
55+
console.log(`⏳ Generating from ${inputPath}`);
56+
}
57+
58+
Performance.start('parser');
59+
if (
60+
config.experimentalParser &&
61+
!isLegacyClient(config) &&
62+
!legacyNameFromConfig(config)
63+
) {
64+
context = parseOpenApiSpec({ config, spec: data });
65+
}
66+
67+
// fallback to legacy parser
68+
if (!context) {
69+
const parsed = parseLegacy({ openApi: data });
70+
client = postProcessClient(parsed, config);
71+
}
72+
Performance.end('parser');
73+
74+
Performance.start('generator');
75+
if (context) {
76+
await generateOutput({ context });
77+
} else if (client) {
78+
await generateLegacyOutput({ client, openApi: data, templates });
79+
}
80+
Performance.end('generator');
81+
82+
Performance.start('postprocess');
83+
if (!config.dryRun) {
84+
processOutput({ config });
85+
86+
const outputPath = process.env.INIT_CWD
87+
? `./${path.relative(process.env.INIT_CWD, config.output.path)}`
88+
: config.output.path;
89+
console.log(`🚀 Done! Your output is in ${outputPath}`);
90+
}
91+
Performance.end('postprocess');
92+
}
93+
94+
if (config.watch.enabled && typeof inputPath === 'string') {
95+
setTimeout(() => {
96+
createClient({ config, templates, watch });
97+
}, config.watch.interval);
98+
}
99+
100+
return context || client;
101+
};

packages/openapi-ts/src/generate/__tests__/class.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ describe('generateLegacyClientClass', () => {
6060
useOptions: true,
6161
watch: {
6262
enabled: false,
63-
interval: 1000,
63+
interval: 1_000,
64+
timeout: 60_000,
6465
},
6566
});
6667

packages/openapi-ts/src/generate/__tests__/core.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ describe('generateLegacyCore', () => {
7575
useOptions: true,
7676
watch: {
7777
enabled: false,
78-
interval: 1000,
78+
interval: 1_000,
79+
timeout: 60_000,
7980
},
8081
});
8182

@@ -166,7 +167,8 @@ describe('generateLegacyCore', () => {
166167
useOptions: true,
167168
watch: {
168169
enabled: false,
169-
interval: 1000,
170+
interval: 1_000,
171+
timeout: 60_000,
170172
},
171173
});
172174

@@ -240,7 +242,8 @@ describe('generateLegacyCore', () => {
240242
useOptions: true,
241243
watch: {
242244
enabled: false,
243-
interval: 1000,
245+
interval: 1_000,
246+
timeout: 60_000,
244247
},
245248
});
246249

packages/openapi-ts/src/generate/__tests__/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ describe('generateIndexFile', () => {
5959
useOptions: true,
6060
watch: {
6161
enabled: false,
62-
interval: 1000,
62+
interval: 1_000,
63+
timeout: 60_000,
6364
},
6465
});
6566

packages/openapi-ts/src/generate/__tests__/output.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ describe('generateLegacyOutput', () => {
6161
useOptions: false,
6262
watch: {
6363
enabled: false,
64-
interval: 1000,
64+
interval: 1_000,
65+
timeout: 60_000,
6566
},
6667
});
6768

packages/openapi-ts/src/getLogs.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Config, UserConfig } from './types/config';
2+
3+
export const getLogs = (userConfig: UserConfig): Config['logs'] => {
4+
let logs: Config['logs'] = {
5+
level: 'info',
6+
path: process.cwd(),
7+
};
8+
if (typeof userConfig.logs === 'string') {
9+
logs.path = userConfig.logs;
10+
} else {
11+
logs = {
12+
...logs,
13+
...userConfig.logs,
14+
};
15+
}
16+
return logs;
17+
};

packages/openapi-ts/src/getSpec.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import {
2+
$RefParser,
3+
getResolvedInput,
4+
type JSONSchema,
5+
sendRequest,
6+
} from '@hey-api/json-schema-ref-parser';
7+
8+
import type { Config } from './types/config';
9+
import type { WatchValues } from './types/types';
10+
11+
interface SpecResponse {
12+
data: JSONSchema;
13+
error?: undefined;
14+
response?: undefined;
15+
}
16+
17+
interface SpecError {
18+
data?: undefined;
19+
error: 'not-modified' | 'not-ok';
20+
response: Response;
21+
}
22+
23+
export const getSpec = async ({
24+
inputPath,
25+
timeout,
26+
watch,
27+
}: {
28+
inputPath: Config['input']['path'];
29+
timeout: number;
30+
watch: WatchValues;
31+
}): Promise<SpecResponse | SpecError> => {
32+
const refParser = new $RefParser();
33+
const resolvedInput = getResolvedInput({ pathOrUrlOrSchema: inputPath });
34+
35+
let arrayBuffer: ArrayBuffer | undefined;
36+
// boolean signals whether the file has **definitely** changed
37+
let hasChanged: boolean | undefined;
38+
let response: Response | undefined;
39+
40+
// no support for watching files and objects for now
41+
if (resolvedInput.type === 'url') {
42+
// do NOT send HEAD request on first run or if unsupported
43+
if (watch.lastValue && watch.isHeadMethodSupported !== false) {
44+
const request = await sendRequest({
45+
init: {
46+
headers: watch.headers,
47+
method: 'HEAD',
48+
},
49+
timeout,
50+
url: resolvedInput.path,
51+
});
52+
response = request.response;
53+
54+
if (!response.ok && watch.isHeadMethodSupported) {
55+
// assume the server is no longer running
56+
// do nothing, it might be restarted later
57+
return {
58+
error: 'not-ok',
59+
response,
60+
};
61+
}
62+
63+
if (watch.isHeadMethodSupported === undefined) {
64+
watch.isHeadMethodSupported = response.ok;
65+
}
66+
67+
if (response.status === 304) {
68+
return {
69+
error: 'not-modified',
70+
response,
71+
};
72+
}
73+
74+
if (hasChanged === undefined) {
75+
const eTag = response.headers.get('ETag');
76+
if (eTag) {
77+
hasChanged = eTag !== watch.headers.get('If-None-Match');
78+
79+
if (hasChanged) {
80+
watch.headers.set('If-None-Match', eTag);
81+
}
82+
}
83+
}
84+
85+
if (hasChanged === undefined) {
86+
const lastModified = response.headers.get('Last-Modified');
87+
if (lastModified) {
88+
hasChanged = lastModified !== watch.headers.get('If-Modified-Since');
89+
90+
if (hasChanged) {
91+
watch.headers.set('If-Modified-Since', lastModified);
92+
}
93+
}
94+
}
95+
96+
// we definitely know the input has not changed
97+
if (hasChanged === false) {
98+
return {
99+
error: 'not-modified',
100+
response,
101+
};
102+
}
103+
}
104+
105+
const fileRequest = await sendRequest({
106+
init: {
107+
method: 'GET',
108+
},
109+
timeout,
110+
url: resolvedInput.path,
111+
});
112+
response = fileRequest.response;
113+
114+
if (!response.ok) {
115+
// assume the server is no longer running
116+
// do nothing, it might be restarted later
117+
return {
118+
error: 'not-ok',
119+
response,
120+
};
121+
}
122+
123+
arrayBuffer = response.body
124+
? await response.arrayBuffer()
125+
: new ArrayBuffer(0);
126+
127+
if (hasChanged === undefined) {
128+
const content = new TextDecoder().decode(arrayBuffer);
129+
hasChanged = content !== watch.lastValue;
130+
watch.lastValue = content;
131+
}
132+
}
133+
134+
if (hasChanged === false) {
135+
return {
136+
error: 'not-modified',
137+
response: response!,
138+
};
139+
}
140+
141+
const data = await refParser.bundle({
142+
arrayBuffer,
143+
pathOrUrlOrSchema: undefined,
144+
resolvedInput,
145+
});
146+
147+
return {
148+
data,
149+
};
150+
};

0 commit comments

Comments
 (0)