Skip to content

Commit

Permalink
fix: watch mode tweaks
Browse files Browse the repository at this point in the history
  • Loading branch information
mrlubos committed Feb 3, 2025
1 parent d0af19e commit 8f7c8ef
Show file tree
Hide file tree
Showing 16 changed files with 1,067 additions and 690 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-deers-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/openapi-ts': patch
---

fix: watch mode handles servers not exposing HEAD method for spec
5 changes: 5 additions & 0 deletions .changeset/happy-eels-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/openapi-ts': patch
---

fix: add watch.timeout option
2 changes: 1 addition & 1 deletion docs/openapi-ts/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ Setting `output.clean` to `false` may result in broken output. Ensure you typech

## Config API

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.
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.

<!--@include: ../examples.md-->
<!--@include: ../sponsors.md-->
2 changes: 1 addition & 1 deletion packages/openapi-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"node": "^18.18.0 || ^20.9.0 || >=22.11.0"
},
"dependencies": {
"@hey-api/json-schema-ref-parser": "1.0.1",
"@hey-api/json-schema-ref-parser": "1.0.2",
"c12": "2.0.1",
"commander": "13.0.0",
"handlebars": "4.7.8"
Expand Down
101 changes: 101 additions & 0 deletions packages/openapi-ts/src/createClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import path from 'node:path';

import { generateLegacyOutput, generateOutput } from './generate/output';
import { getSpec } from './getSpec';
import type { IR } from './ir/types';
import { parseLegacy, parseOpenApiSpec } from './openApi';
import { processOutput } from './processOutput';
import type { Client } from './types/client';
import type { Config } from './types/config';
import type { WatchValues } from './types/types';
import { isLegacyClient, legacyNameFromConfig } from './utils/config';
import type { Templates } from './utils/handlebars';
import { Performance } from './utils/performance';
import { postProcessClient } from './utils/postprocess';

export const createClient = async ({
config,
templates,
watch: _watch,
}: {
config: Config;
templates: Templates;
watch?: WatchValues;
}) => {
const inputPath = config.input.path;
const timeout = config.watch.timeout;

const watch: WatchValues = _watch || { headers: new Headers() };

Performance.start('spec');
const { data, error, response } = await getSpec({
inputPath,
timeout,
watch,
});
Performance.end('spec');

// throw on first run if there's an error to preserve user experience
// if in watch mode, subsequent errors won't throw to gracefully handle
// cases where server might be reloading
if (error && !_watch) {
throw new Error(
`Request failed with status ${response.status}: ${response.statusText}`,
);
}

let client: Client | undefined;
let context: IR.Context | undefined;

if (data) {
if (_watch) {
console.clear();
console.log(`⏳ Input changed, generating from ${inputPath}`);
} else {
console.log(`⏳ Generating from ${inputPath}`);
}

Performance.start('parser');
if (
config.experimentalParser &&
!isLegacyClient(config) &&
!legacyNameFromConfig(config)
) {
context = parseOpenApiSpec({ config, spec: data });
}

// fallback to legacy parser
if (!context) {
const parsed = parseLegacy({ openApi: data });
client = postProcessClient(parsed, config);
}
Performance.end('parser');

Performance.start('generator');
if (context) {
await generateOutput({ context });
} else if (client) {
await generateLegacyOutput({ client, openApi: data, templates });
}
Performance.end('generator');

Performance.start('postprocess');
if (!config.dryRun) {
processOutput({ config });

const outputPath = process.env.INIT_CWD
? `./${path.relative(process.env.INIT_CWD, config.output.path)}`
: config.output.path;
console.log(`🚀 Done! Your output is in ${outputPath}`);
}
Performance.end('postprocess');
}

if (config.watch.enabled && typeof inputPath === 'string') {
setTimeout(() => {
createClient({ config, templates, watch });
}, config.watch.interval);
}

return context || client;
};
17 changes: 17 additions & 0 deletions packages/openapi-ts/src/getLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Config, UserConfig } from './types/config';

export const getLogs = (userConfig: UserConfig): Config['logs'] => {
let logs: Config['logs'] = {
level: 'info',
path: process.cwd(),
};
if (typeof userConfig.logs === 'string') {
logs.path = userConfig.logs;
} else {
logs = {
...logs,
...userConfig.logs,
};
}
return logs;
};
150 changes: 150 additions & 0 deletions packages/openapi-ts/src/getSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
$RefParser,
getResolvedInput,
type JSONSchema,
sendRequest,
} from '@hey-api/json-schema-ref-parser';

import type { Config } from './types/config';
import type { WatchValues } from './types/types';

interface SpecResponse {
data: JSONSchema;
error?: undefined;
response?: undefined;
}

interface SpecError {
data?: undefined;
error: 'not-modified' | 'not-ok';
response: Response;
}

export const getSpec = async ({
inputPath,
timeout,
watch,
}: {
inputPath: Config['input']['path'];
timeout: number;
watch: WatchValues;
}): Promise<SpecResponse | SpecError> => {
const refParser = new $RefParser();
const resolvedInput = getResolvedInput({ pathOrUrlOrSchema: inputPath });

let arrayBuffer: ArrayBuffer | undefined;
// boolean signals whether the file has **definitely** changed
let hasChanged: boolean | undefined;
let response: Response | undefined;

// no support for watching files and objects for now
if (resolvedInput.type === 'url') {
// do NOT send HEAD request on first run or if unsupported
if (watch.lastValue && watch.isHeadMethodSupported !== false) {
const request = await sendRequest({
init: {
headers: watch.headers,
method: 'HEAD',
},
timeout,
url: resolvedInput.path,
});
response = request.response;

if (!response.ok && watch.isHeadMethodSupported) {
// assume the server is no longer running
// do nothing, it might be restarted later
return {
error: 'not-ok',
response,
};
}

if (watch.isHeadMethodSupported === undefined) {
watch.isHeadMethodSupported = response.ok;
}

if (response.status === 304) {
return {
error: 'not-modified',
response,
};
}

if (hasChanged === undefined) {

Check notice

Code scanning / CodeQL

Unneeded defensive code Note

This guard always evaluates to true.
const eTag = response.headers.get('ETag');
if (eTag) {
hasChanged = eTag !== watch.headers.get('If-None-Match');

if (hasChanged) {
watch.headers.set('If-None-Match', eTag);
}
}
}

if (hasChanged === undefined) {
const lastModified = response.headers.get('Last-Modified');
if (lastModified) {
hasChanged = lastModified !== watch.headers.get('If-Modified-Since');

if (hasChanged) {
watch.headers.set('If-Modified-Since', lastModified);
}
}
}

// we definitely know the input has not changed
if (hasChanged === false) {
return {
error: 'not-modified',
response,
};
}
}

const fileRequest = await sendRequest({
init: {
method: 'GET',
},
timeout,
url: resolvedInput.path,
});
response = fileRequest.response;

if (!response.ok) {
// assume the server is no longer running
// do nothing, it might be restarted later
return {
error: 'not-ok',
response,
};
}

arrayBuffer = response.body
? await response.arrayBuffer()
: new ArrayBuffer(0);

if (hasChanged === undefined) {
const content = new TextDecoder().decode(arrayBuffer);
hasChanged = content !== watch.lastValue;
watch.lastValue = content;
}
}

if (hasChanged === false) {
return {
error: 'not-modified',
response: response!,
};
}

const data = await refParser.bundle({
arrayBuffer,
pathOrUrlOrSchema: undefined,
resolvedInput,
});

return {
data,
};
};
Loading

0 comments on commit 8f7c8ef

Please sign in to comment.