blob: 83ddd89aaee84446cd3ac37131f142799505a53a [file] [log] [blame]
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const ts = require("typescript");
const fs = require("fs");
/* This library does three things to make openapi-typescript generation easier to use.
* 1. Creates capitalized exports for Paths and Operations
* 2. Alias Variables based either on the Path name or the Operation ID, such as:
* export type ListProjectsVariables = operations['listProjects']['parameters']['query'];
* 3. Aliases the returned data types, such as:
* export type ConnectionArgument = components['schemas']['ConnectionArgument'];
*/
/* Finds all words, capitalizes them, and removes all other characters. */
const toPascalCase = (str) =>
(str.match(/[a-zA-Z0-9]+/g) || [])
.map((w) => `${w.charAt(0).toUpperCase()}${w.slice(1)}`)
.join("");
/* Adds a prefix to a type prop as necessary.
* ('', 'components') => 'components'
*/
const prefixPath = (rootPrefix, prop) =>
rootPrefix ? `${rootPrefix}['${prop}']` : prop;
// Recursively find child nodes by name.
const findNode = (node, ...names) => {
if (!node || names.length === 0) return node;
const children = node.members || node.type.members;
if (!children) {
return undefined;
}
const child = children.find((c) => c.name?.text === names[0]);
return findNode(child, ...names.slice(1));
};
// Generate Variable Type Aliases for a given path or operation
const generateVariableAliases = (node, operationPath, operationName) => {
const variableTypes = [];
const hasPath = !!findNode(node, "parameters", "path");
const hasQuery = !!findNode(node, "parameters", "query");
const hasBody = !!findNode(
node,
"requestBody",
"content",
"application/json"
);
if (hasPath) variableTypes.push(`${operationPath}['parameters']['path']`);
if (hasQuery) variableTypes.push(`${operationPath}['parameters']['query']`);
if (hasBody)
variableTypes.push(
`${operationPath}['requestBody']['content']['application/json']`
);
if (variableTypes.length === 0) return "";
const typeName = `${toPascalCase(operationName)}Variables`;
return [
typeName,
`export type ${typeName} = CamelCasedPropertiesDeep<${variableTypes.join(
" & "
)}>;`,
];
};
// Generate Type Aliases
const generateAliases = (rootNode, writeText, prefix = "") => {
// Loop through the root AST nodes of the file
ts.forEachChild(rootNode, (node) => {
// Response Data Types
if (ts.isInterfaceDeclaration(node) && node.name?.text === "components") {
const schemaMemberNames = findNode(node, "schemas").type.members.map(
(n) => n.name?.text
);
const types = schemaMemberNames.map((n) => [
`${n}`,
`export type ${n} = CamelCasedPropertiesDeep<${prefixPath(
prefix,
"components"
)}['schemas']['${n}']>;`,
]);
if (types.length) {
writeText.push(["comment", `Types for returned data ${prefix}`]);
writeText.push(...types);
}
}
// Paths referencing an operation are skipped
if (node.name?.text === "paths") {
if (!prefix) {
writeText.push(["comment", "Alias paths to PascalCase."]);
writeText.push(["Paths", "export type Paths = paths;"]);
}
const types = [];
(node.members || node.type.members).forEach((path) => {
const methodNames = path.type.members.map((m) => m.name.text);
const methodTypes = methodNames.map((m) =>
generateVariableAliases(
findNode(path, m),
`${prefixPath(prefix, "paths")}['${path.name?.text}']['${m}']`,
`${path.name.text}${toPascalCase(m)}`
)
);
types.push(...methodTypes.filter((m) => !!m));
});
if (types.length) {
writeText.push([
"comment",
`Types for path operation variables ${prefix}`,
]);
writeText.push(...types);
}
}
// operationIds are defined
if (node.name?.text === "operations") {
if (!prefix) {
writeText.push(["comment", "Alias operations to PascalCase."]);
writeText.push(["Operations", "export type Operations = operations;"]);
}
const types = (node.members || node.type.members).map((operation) =>
generateVariableAliases(
operation,
`${prefixPath(prefix, "operations")}['${operation.name.text}']`,
operation.name.text
)
);
if (types.length) {
writeText.push(["comment", `Types for operation variables ${prefix}`]);
writeText.push(...types);
writeText.push("\n");
}
}
// recursively call this for any externals
if (ts.isInterfaceDeclaration(node) && node.name?.text === "external") {
node.members.forEach((external) => {
generateAliases(
external.type,
writeText,
`external['${external.name.text}']`
);
});
}
});
};
const license = `/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/`;
function generate(file) {
// Create a Program to represent the project, then pull out the
// source file to parse its AST.
const program = ts.createProgram([file], { allowJs: true });
const sourceFile = program.getSourceFile(file);
const writeText = [];
writeText.push(["block", license]);
writeText.push(["comment", "eslint-disable"]);
// eslint-disable-next-line quotes
writeText.push([
"block",
`import type { CamelCasedPropertiesDeep } from 'type-fest';`,
]);
writeText.push(["block", sourceFile.text]);
generateAliases(sourceFile, writeText);
const finalText = writeText
// Deduplicate types
.map((pair) => {
// keep all comments and code blocks
if (pair[0] === "comment" || pair[0] === "block") return pair;
// return the first instance of this key only
const firstInstance = writeText.find((p) => p[0] === pair[0]);
return firstInstance === pair
? pair
: ["comment", `Duplicate removed: ${pair[1]}`];
})
// Remove undefined created above
.filter((p) => !!p)
// Escape comments and flatten.
.map((pair) => (pair[0] === "comment" ? `\n/* ${pair[1]} */` : pair[1]))
.join("\n");
fs.writeFileSync(file, finalText, (err) => {
if (err) {
console.error(err);
}
});
}
generate(process.argv[2]);