Skip to content

Commit 90e221c

Browse files
authored
fix: add log handler for crd generator (#28)
1 parent 1c6cf3a commit 90e221c

File tree

3 files changed

+191
-28
lines changed

3 files changed

+191
-28
lines changed

src/cli.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,15 @@ void yargs(hideBin(process.argv))
3737
})
3838
.demandOption(["source", "directory"]);
3939
},
40-
argv => {
41-
void generate(argv as GenerateOptions);
40+
async argv => {
41+
const opts = argv as unknown as GenerateOptions;
42+
opts.logFn = console.log;
43+
44+
try {
45+
await generate(opts);
46+
} catch (e) {
47+
console.log(`\n❌ ${e.message}`);
48+
}
4249
},
4350
)
4451
.parse();

src/generate.test.ts

Lines changed: 132 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,28 +111,26 @@ jest.mock("./fluent", () => ({
111111
K8s: jest.fn(),
112112
}));
113113

114-
describe("CRD to TypeScript Conversion", () => {
114+
describe("CRD Generate", () => {
115115
const originalReadFileSync = fs.readFileSync;
116116

117-
jest.isolateModules(() => {
118-
jest.spyOn(fs, "existsSync").mockReturnValue(true);
119-
jest.spyOn(fs, "readFileSync").mockImplementation((...args) => {
120-
// Super janky hack due ot source-map-support calling readFileSync internally
121-
if (args[0].toString().includes("test-crd.yaml")) {
122-
return sampleYaml;
123-
}
124-
return originalReadFileSync(...args);
125-
});
126-
jest.spyOn(fs, "mkdirSync").mockReturnValue(undefined);
127-
jest.spyOn(fs, "writeFileSync").mockReturnValue(undefined);
117+
jest.spyOn(fs, "existsSync").mockReturnValue(true);
118+
jest.spyOn(fs, "readFileSync").mockImplementation((...args) => {
119+
// Super janky hack due ot source-map-support calling readFileSync internally
120+
if (args[0].toString().includes("test-crd.yaml")) {
121+
return sampleYaml;
122+
}
123+
return originalReadFileSync(...args);
128124
});
125+
const mkdirSyncSpy = jest.spyOn(fs, "mkdirSync").mockReturnValue(undefined);
126+
const writeFileSyncSpy = jest.spyOn(fs, "writeFileSync").mockReturnValue(undefined);
129127

130128
beforeEach(() => {
131129
jest.clearAllMocks();
132130
});
133131

134132
test("converts CRD to TypeScript", async () => {
135-
const options = { source: "test-crd.yaml", language: "ts" }; // specify your options
133+
const options = { source: "test-crd.yaml", language: "ts", logFn: jest.fn() };
136134

137135
const actual = await generate(options);
138136
const expectedMovie = [
@@ -187,7 +185,7 @@ describe("CRD to TypeScript Conversion", () => {
187185
});
188186

189187
test("converts CRD to TypeScript with plain option", async () => {
190-
const options = { source: "test-crd.yaml", language: "ts", plain: true }; // specify your options
188+
const options = { source: "test-crd.yaml", language: "ts", plain: true, logFn: jest.fn() };
191189

192190
const actual = await generate(options);
193191
const expectedMovie = [
@@ -227,8 +225,127 @@ describe("CRD to TypeScript Conversion", () => {
227225
expect(actual["book-v2"]).toEqual(expectedBookV2);
228226
});
229227

228+
test("converts CRD to TypeScript with other options", async () => {
229+
const options = {
230+
source: "test-crd.yaml",
231+
npmPackage: "test-package",
232+
logFn: jest.fn(),
233+
};
234+
235+
const actual = await generate(options);
236+
const expectedMovie = [
237+
"// This file is auto-generated by test-package, do not edit manually\n",
238+
'import { GenericKind, RegisterKind } from "test-package";\n',
239+
"/**",
240+
" * Movie nerd",
241+
" */",
242+
"export class Movie extends GenericKind {",
243+
" spec?: Spec;",
244+
"}",
245+
"",
246+
"export interface Spec {",
247+
" author?: string;",
248+
" title?: string;",
249+
"}",
250+
"",
251+
"RegisterKind(Movie, {",
252+
' group: "example.com",',
253+
' version: "v1",',
254+
' kind: "Movie",',
255+
"});",
256+
];
257+
const expectedBookV1 = [
258+
"// This file is auto-generated by test-package, do not edit manually\n",
259+
'import { GenericKind, RegisterKind } from "test-package";\n',
260+
"/**",
261+
" * Book nerd",
262+
" */",
263+
"export class Book extends GenericKind {",
264+
" spec?: Spec;",
265+
"}",
266+
"",
267+
"export interface Spec {",
268+
" author?: string;",
269+
" title?: string;",
270+
"}",
271+
"",
272+
"RegisterKind(Book, {",
273+
' group: "example.com",',
274+
' version: "v1",',
275+
' kind: "Book",',
276+
"});",
277+
];
278+
const expectedBookV2 = expectedBookV1
279+
.filter(line => !line.includes("title?"))
280+
.map(line => line.replace("v1", "v2"));
281+
282+
expect(actual["movie-v1"]).toEqual(expectedMovie);
283+
expect(actual["book-v1"]).toEqual(expectedBookV1);
284+
expect(actual["book-v2"]).toEqual(expectedBookV2);
285+
});
286+
287+
test("converts CRD to TypeScript and writes to the given directory", async () => {
288+
const options = {
289+
source: "test-crd.yaml",
290+
directory: "test",
291+
logFn: jest.fn(),
292+
};
293+
294+
await generate(options);
295+
const expectedMovie = [
296+
"// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n",
297+
'import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n',
298+
"/**",
299+
" * Movie nerd",
300+
" */",
301+
"export class Movie extends GenericKind {",
302+
" spec?: Spec;",
303+
"}",
304+
"",
305+
"export interface Spec {",
306+
" author?: string;",
307+
" title?: string;",
308+
"}",
309+
"",
310+
"RegisterKind(Movie, {",
311+
' group: "example.com",',
312+
' version: "v1",',
313+
' kind: "Movie",',
314+
"});",
315+
];
316+
const expectedBookV1 = [
317+
"// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n",
318+
'import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n',
319+
"/**",
320+
" * Book nerd",
321+
" */",
322+
"export class Book extends GenericKind {",
323+
" spec?: Spec;",
324+
"}",
325+
"",
326+
"export interface Spec {",
327+
" author?: string;",
328+
" title?: string;",
329+
"}",
330+
"",
331+
"RegisterKind(Book, {",
332+
' group: "example.com",',
333+
' version: "v1",',
334+
' kind: "Book",',
335+
"});",
336+
];
337+
const expectedBookV2 = expectedBookV1
338+
.filter(line => !line.includes("title?"))
339+
.map(line => line.replace("v1", "v2"));
340+
341+
expect(mkdirSyncSpy).toHaveBeenCalledWith("test", { recursive: true });
342+
expect(writeFileSyncSpy).toHaveBeenCalledWith("test/movie-v1.ts", expectedMovie.join("\n"));
343+
expect(writeFileSyncSpy).toHaveBeenCalledWith("test/book-v1.ts", expectedBookV1.join("\n"));
344+
expect(writeFileSyncSpy).toHaveBeenCalledWith("test/book-v2.ts", expectedBookV2.join("\n"));
345+
});
346+
230347
test("converts CRD to Go", async () => {
231-
const options = { source: "test-crd.yaml", language: "go" }; // specify your options
348+
const options = { source: "test-crd.yaml", language: "go", logFn: jest.fn() };
232349

233350
const actual = await generate(options);
234351
const expectedMovie = [

src/generate.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { fetch } from "./fetch";
1616
import { K8s } from "./fluent";
1717
import { CustomResourceDefinition } from "./upstream";
18+
import { LogFn } from "./types";
1819

1920
export interface GenerateOptions {
2021
/** The source URL, yaml file path or K8s CRD name */
@@ -25,6 +26,10 @@ export interface GenerateOptions {
2526
plain?: boolean;
2627
/** The language to generate types in */
2728
language?: string | TargetLanguage;
29+
/** Override the NPM package to import when generating formatted Typescript */
30+
npmPackage?: string;
31+
/** Log function callback */
32+
logFn: LogFn;
2833
}
2934

3035
/**
@@ -44,23 +49,32 @@ async function convertCRDtoTS(
4449
const results: Record<string, string[]> = {};
4550

4651
for (const match of crd.spec.versions) {
52+
const version = match.name;
53+
4754
// Get the schema from the matched version
4855
const schema = JSON.stringify(match?.schema?.openAPIV3Schema);
4956

5057
// Create a new JSONSchemaInput
5158
const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
5259

60+
opts.logFn(`- Generating ${crd.spec.group}/${version} types for ${name}`);
61+
5362
// Add the schema to the input
5463
await schemaInput.addSource({ name, schema });
5564

5665
// Create a new InputData object
5766
const inputData = new InputData();
5867
inputData.addInput(schemaInput);
5968

69+
// If the language is not specified, default to TypeScript
70+
if (!opts.language) {
71+
opts.language = "ts";
72+
}
73+
6074
// Generate the types
6175
const out = await quicktype({
6276
inputData,
63-
lang: opts.language || "ts",
77+
lang: opts.language,
6478
rendererOptions: { "just-types": "true" },
6579
});
6680

@@ -73,11 +87,15 @@ async function convertCRDtoTS(
7387

7488
// If the language is TypeScript and plain is not specified, wire up the fluent client
7589
if (opts.language === "ts" && !opts.plain) {
90+
if (!opts.npmPackage) {
91+
opts.npmPackage = "kubernetes-fluent-client";
92+
}
93+
7694
processedLines.unshift(
7795
// Add warning that the file is auto-generated
78-
`// This file is auto-generated by kubernetes-fluent-client, do not edit manually\n`,
96+
`// This file is auto-generated by ${opts.npmPackage}, do not edit manually\n`,
7997
// Add the imports before any other lines
80-
`import { GenericKind, RegisterKind } from "kubernetes-fluent-client";\n`,
98+
`import { GenericKind, RegisterKind } from "${opts.npmPackage}";\n`,
8199
);
82100

83101
// Replace the interface with a named class that extends GenericKind
@@ -91,13 +109,13 @@ async function convertCRDtoTS(
91109
// Add the RegisterKind call
92110
processedLines.push(`RegisterKind(${name}, {`);
93111
processedLines.push(` group: "${crd.spec.group}",`);
94-
processedLines.push(` version: "${match.name}",`);
112+
processedLines.push(` version: "${version}",`);
95113
processedLines.push(` kind: "${name}",`);
96114
processedLines.push(`});`);
97115
}
98116

99117
const finalContents = processedLines.join("\n");
100-
const fileName = `${name.toLowerCase()}-${match.name.toLowerCase()}`;
118+
const fileName = `${name.toLowerCase()}-${version.toLowerCase()}`;
101119

102120
// If an output file is specified, write the output to the file
103121
if (opts.directory) {
@@ -119,15 +137,17 @@ async function convertCRDtoTS(
119137
/**
120138
* Reads a CustomResourceDefinition from a file, the cluster or the internet
121139
*
122-
* @param source The source to read from (file path, cluster or URL)
140+
* @param opts The options to use when reading
123141
* @returns A promise that resolves when the CustomResourceDefinition has been read
124142
*/
125-
async function readOrFetchCrd(source: string): Promise<CustomResourceDefinition[]> {
143+
async function readOrFetchCrd(opts: GenerateOptions): Promise<CustomResourceDefinition[]> {
144+
const { source, logFn } = opts;
126145
const filePath = path.join(process.cwd(), source);
127146

128147
// First try to read the source as a file
129148
try {
130149
if (fs.existsSync(filePath)) {
150+
logFn(`Attempting to load ${source} as a local file`);
131151
const payload = fs.readFileSync(filePath, "utf8");
132152
return loadAllYaml(payload) as CustomResourceDefinition[];
133153
}
@@ -141,6 +161,7 @@ async function readOrFetchCrd(source: string): Promise<CustomResourceDefinition[
141161

142162
// If the source is a URL, fetch it
143163
if (url.protocol === "http:" || url.protocol === "https:") {
164+
logFn(`Attempting to load ${source} as a URL`);
144165
const { ok, data } = await fetch<string>(source);
145166

146167
// If the request failed, throw an error
@@ -151,14 +172,22 @@ async function readOrFetchCrd(source: string): Promise<CustomResourceDefinition[
151172
return loadAllYaml(data) as CustomResourceDefinition[];
152173
}
153174
} catch (e) {
154-
// Ignore errors
175+
// If invalid, ignore the error
176+
if (e.code !== "ERR_INVALID_URL") {
177+
throw new Error(e);
178+
}
155179
}
156180

157181
// Finally, if the source is not a file or URL, try to read it as a CustomResourceDefinition from the cluster
158182
try {
183+
logFn(`Attempting to read ${source} from the current Kubernetes context`);
159184
return [await K8s(CustomResourceDefinition).Get(source)];
160185
} catch (e) {
161-
throw new Error(`Failed to read ${source} as a file, url or K8s CRD: ${e}`);
186+
throw new Error(
187+
`Failed to read ${source} as a file, url or K8s CRD: ${
188+
e.data?.message || "Cluster not available"
189+
}`,
190+
);
162191
}
163192
}
164193

@@ -169,11 +198,14 @@ async function readOrFetchCrd(source: string): Promise<CustomResourceDefinition[
169198
* @returns A promise that resolves when the TypeScript types have been generated
170199
*/
171200
export async function generate(opts: GenerateOptions) {
172-
const crds = await readOrFetchCrd(opts.source);
201+
const crds = (await readOrFetchCrd(opts)).filter(crd => !!crd);
173202
const results: Record<string, string[]> = {};
174203

204+
opts.logFn("");
205+
175206
for (const crd of crds) {
176-
if (!crd || crd.kind !== "CustomResourceDefinition" || !crd.spec?.versions?.length) {
207+
if (crd.kind !== "CustomResourceDefinition" || !crd.spec?.versions?.length) {
208+
opts.logFn(`Skipping ${crd?.metadata?.name}, it does not appear to be a CRD`);
177209
// Ignore empty and non-CRD objects
178210
continue;
179211
}
@@ -185,5 +217,12 @@ export async function generate(opts: GenerateOptions) {
185217
}
186218
}
187219

220+
if (opts.directory) {
221+
// Notify the user that the files have been generated
222+
opts.logFn(
223+
`\n✅ Generated ${Object.keys(results).length} files in the ${opts.directory} directory`,
224+
);
225+
}
226+
188227
return results;
189228
}

0 commit comments

Comments
 (0)