openapi-ts icon indicating copy to clipboard operation
openapi-ts copied to clipboard

Add an option to modify generated type names via a builder or a suffix

Open MrLepage opened this issue 1 year ago • 1 comments

Summary: I would like to request a feature that allows users to customize the names of the generated types through a builder or by adding a suffix. This enhancement would provide more flexibility and clarity, especially when integrating generated types into existing projects where specific naming conventions are required.

Use Case: In scenarios where the generated types need to adhere to particular naming conventions or to avoid naming conflicts within a project, the ability to modify the names of these types is essential. For example, adding a suffix like Dto to the generated types can help maintain consistency and improve readability.

Proposed Solution: • Introduce an option in the configuration to allow users to specify a suffix or a custom name pattern for generated types. • Provide clear documentation and examples on how to use this new feature.

Benefits: • Enhances the flexibility of type generation. • Allows better integration with projects that have strict naming conventions. • Reduces potential conflicts and confusion in large codebases.

I believe this feature would significantly improve the developer experience and extend the utility of the type generation tool. Thank you for considering this request!

MrLepage avatar Aug 20 '24 10:08 MrLepage

@MrLepage This will be definitely supported in the future. Let me put a vote on this to see how important it is compared to other features!

mrlubos avatar Aug 20 '24 11:08 mrlubos

A temporary solution to this issue could be the following script that we started using to be able to migrate to openapi-ts while waiting for this feature. Just run it with npm after calling openapi-ts.

E.g.:

openapi-ts && node scripts/add-api-suffix.cjs --suffix '__api' --dir 'src/api/'

const fs = require("fs");
const path = require("path");
const process = require("process");

const TYPES_FILE = "types.gen.ts";
const INDEX_FILE = "index.ts";

function parseProcessArgs() {
  const cmdArgRegex = /^--(\w+)$/;
  const valueRegex = /^['"]?(.*?)['"]?$/;

  const argsMap = {};
  let lastCmdArg = null;
  for (const arg of process.argv.slice(2)) {
    const match = arg.match(cmdArgRegex);
    if (match) {
      argsMap[match[1]] = true;
      lastCmdArg = match[1];
    }
    else {
      const value = arg.match(valueRegex)[1];
      if (lastCmdArg) {
        if (argsMap[lastCmdArg] && argsMap[lastCmdArg] !== true) {
          argsMap[lastCmdArg] = [...argsMap[lastCmdArg], value];
          continue;
        }
        argsMap[lastCmdArg] = value;
      }
    }
  }

  return argsMap;
}

const parsedArgs = parseProcessArgs();

// Get process arguments
const suffix = parsedArgs.suffix;
let dir = parsedArgs.dir;
const encoding = parsedArgs.encoding ?? "utf8";
const exportTanstackQueryFromIndex = parsedArgs.exportTanstackQueryFromIndex ?? false;

if (!suffix) {
  throw new Error("No suffix provided, exiting");
}

if (!dir) {
  console.info("No dir provided, using cwd");
  dir = process.cwd();
}

const typesFilePath = path.join(dir, TYPES_FILE);
const indexFilePath = path.join(dir, INDEX_FILE);

if (!fs.existsSync(typesFilePath)) {
  throw new Error(`File ${typesFilePath} does not exist`);
}

const exportTypesRegExp = /^export (type|enum) (\w+)/gm;
const singleImportNameRegExp = /(\s*(\w+),?\s*)/g;

function makeImportTypesRegExp(currentDir) {
  const relativeTypesFilePath = path.relative(currentDir, typesFilePath);
  const parsedFilePath = path.parse(relativeTypesFilePath);
  let escapedFilePath = path.join(parsedFilePath.dir, parsedFilePath.name).replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
  if (path.resolve(currentDir) === path.resolve(path.parse(typesFilePath).dir)) {
    escapedFilePath = `\\./${escapedFilePath}`;
  }
  return new RegExp(`^import type {((\\s*(\\w+),?\\s*)+)} from ["']${escapedFilePath}["'];`, "m");
}

// First, replace all occurences of exported types with the same type name plus the suffix
const typesFileContent = fs.readFileSync(typesFilePath, encoding);
const matches = typesFileContent.matchAll(exportTypesRegExp);

const exportNames = [];
for (const match of matches) {
  exportNames.push(match[2]);
}

let newTypesFileContent = typesFileContent.replace(exportTypesRegExp, `export $1 $2${suffix}`);
for (const exportName of exportNames) {
  const exportNameRegExp = new RegExp(`\\b${exportName}\\b`, "g");
  newTypesFileContent = newTypesFileContent.replace(exportNameRegExp, `${exportName}${suffix}`);
}
fs.writeFileSync(typesFilePath, newTypesFileContent);

console.log(`\u{2705} Added suffix to exported types in '${typesFilePath}'`);

function adjustImportedTypeNames(content, dir) {
  let newContent = content;
  const importTypesRegExp = makeImportTypesRegExp(dir);

  const match = content.match(importTypesRegExp);
  if (match === null) {
    return { newContent, gotAdjusted: false };
  }

  const matchedString = match[1];
  const singleImportMatches = matchedString.matchAll(singleImportNameRegExp);

  const importNames = [];
  for (const singleImportMatch of singleImportMatches) {
    importNames.push(singleImportMatch[2]);
  }

  for (const importName of importNames) {
    const importNameRegExp = new RegExp(`\\b${importName}\\b`, "g");
    newContent = newContent.replace(importNameRegExp, `${importName}${suffix}`);
  }

  return { newContent, gotAdjusted: true };
}

// Then, replace all occurences of imported types within all files in the given directory with the same type name plus the suffix
function recursivelyAdjustImportedTypeNames(dir) {
  const filesAndDirs = fs.readdirSync(dir);
  for (const fileOrDir of filesAndDirs) {
    const fileOrDirPath = path.join(dir, fileOrDir);
    if (fs.statSync(fileOrDirPath).isDirectory()) {
      recursivelyAdjustImportedTypeNames(fileOrDirPath);
      continue;
    }

    const fileContent = fs.readFileSync(fileOrDirPath, encoding);
    const { newContent, gotAdjusted } = adjustImportedTypeNames(fileContent, dir);
    fs.writeFileSync(fileOrDirPath, newContent);

    if (gotAdjusted) {
      console.log(`\u{2705} Added suffix to imported types in '${fileOrDirPath}'`);
    }
  }
}

recursivelyAdjustImportedTypeNames(dir);

// Finally, re-export all TanstackQuery exports from the index file
if (exportTanstackQueryFromIndex) {
  let indexFileContent = fs.readFileSync(indexFilePath, encoding);
  indexFileContent += `\nexport * from './@tanstack/react-query.gen';\n`;
  fs.writeFileSync(indexFilePath, indexFileContent);
  console.log(`\u{2705} Added re-export statement for tanstack query exports in '${indexFilePath}'`);
}

rubenthoms avatar Jan 08 '25 12:01 rubenthoms

Hey all, v0.79.0 will add a bunch of new customization options to the TypeScript plugin. Let me know if you're still missing certain capabilities once you try it!

mrlubos avatar Jul 11 '25 06:07 mrlubos

hey @mrlubos , this seems like it also addresses https://github.com/hey-api/openapi-ts/issues/558 ? Right?

IAL32 avatar Jul 16 '25 16:07 IAL32

@IAL32 No, that's a separate issue

mrlubos avatar Jul 16 '25 17:07 mrlubos

@MrLepage This will be definitely supported in the future. Let me put a vote on this to see how important it is compared to other features!

hi @mrlubos , the current case config is for generated enum values. Can we have a option to config for the const name itself? The use case is like:

export const THE_KIND = {...} as const
export type TheKind = (typeof THE_KIND)[keyof typeof THE_KIND]

so we can differentiate the const and type when importing.

kid1412621 avatar Jul 25 '25 09:07 kid1412621

@kid1412621 can you describe the issue more? I've never felt the confusion/need to differentiate between them

mrlubos avatar Jul 25 '25 09:07 mrlubos

@kid1412621 can you describe the issue more? I've never felt the confusion/need to differentiate between them

hi, imagine this usage:

import { type TheKind } from '@/types'
import { TheKind } from '@/types' // it conflicts

let x: TheKind | null = null
x = TheKind.KIND_1

but if we can rename the const:

import { type TheKind } from '@/types'
import { THE_KIND } from '@/types' // currently we use alias: import { TheKind as THE_KIND } from '@types'

let x: TheKind | null = null
x = THE_KIND.KIND_1

kid1412621 avatar Jul 25 '25 09:07 kid1412621

Do you have any suggestion for naming this option?

mrlubos avatar Jul 25 '25 09:07 mrlubos

Do you have any suggestion for naming this option?

I think we need a way to configure renaming the const itself. Like SNAKE_CASE for const and PascalCase for type. Or able to add appendix like XXXEnum or sth.

kid1412621 avatar Jul 25 '25 09:07 kid1412621

A custom plugin could be implemented.

// openapi-ts.config.ts


import {
  UserConfig,
  defineConfig,
  DefinePlugin,
  definePluginConfig,
} from "@hey-api/openapi-ts";

type MyEndpointPlugin = DefinePlugin<{
  omitControllerName?: boolean;
  name: string;
}>;
export const MyEndpointPlugin = definePluginConfig({
  config: { omitControllerName: true },
  dependencies: ["@hey-api/typescript"],
  handler: (({ plugin }) => {
    plugin.forEach("operation", (arg) => {
      arg.operation.id = arg.operation.id + "Suffix"
    });
  }) as MyEndpointPlugin["Handler"],
  name: "my-plugin",
  output: "my-plugin",
} as MyEndpointPlugin["Config"]);

const config = await defineConfig({
  input: "http://localhost/openapi-spec-json", // or a remote URL
  output: {
    path: "./app/lib/api/gen/", // where generated files go
    format: "prettier", // auto-format output
    lint: "eslint", // run eslint on generated files
    // optional: single file vs modular structure
    // target: "typescript",   // defaults to TS
    clean: true,

    case: "camelCase",
  },

  // interactive: true,
  //   dryRun: true,

  parser: {},

  plugins: [
    MyEndpointPlugin({ omitControllerName: true }),

    "@hey-api/client-axios",
    "@tanstack/react-query",
  ],
});
export default config;

kavyantic avatar Aug 27 '25 12:08 kavyantic