Using custom templates and configs for swagger-typescript-api

swagger-typescript-api is a powerful tool for generating code based on OpenApi-contacts, the process of working with which I talked about in the previous article. I also mentioned there that it can be customized to suit the needs of a specific project using your own templates.

Precisely custom templates and a bonus, custom configuration, will be covered in the current article. Go!

To use custom templates, you must first do the following:

  1. Copy from swagger-typescript-api repository templates to your project:

    1. From /templates/default to generate a common file with APIs and types;

    2. From /templates/modular to generate separate files with APIs and types;

    3. From /templates/base templates that are used in the first two options;

  2. Add a flag --templates PATH_TO_YOUR_TEMPLATES for the code generation command;

  3. And directly modify the templates.

This is exactly what is written in the tool’s doc. However, there are a number of points that are not obvious. Let's look at everything step by step using my task as an example.

As I wrote in the previous article, we use a custom hook to work with requests. It needs to be fed a function that creates a new instance of the API class and calls the desired method. It looks like this:

const [getContactDataPhones, contactDataPhonesListRd] = useFetchApiCallRd(
  getContactDataApi, 'phonesApiGetPhones',
);

We are interested in the function getContactDataApi:

export const getContactDataApi = (
  config: ApiConfig,
  params: RequestParams,
) => ({
  emailsApiGetEmails: (query: { pfpId: number; }) => new ContactDataApi(config).emails.getEmails(query, params),

  phonesApiGetPhones: (query?: { pfpId?: number; leadId?: number; phoneType?: IPhoneType[]; isFormal?: boolean; }) => (
    new ContactDataApi(config).phones.getPhones(query, params)
  ),

  phonesApiPostPhones: (data: INewPhone) => new ContactDataApi(config).phones.postPhones(data, params),

  phonesApiGetMaskedPhone: (phoneNumber: string) => (
    new ContactDataApi(config).phones.getMaskedPhone(phoneNumber, params)
  ),

  phonesApiPatchPhone: (contactDataId: string, data: IUpdatePhone) => (
    new ContactDataApi(config).phones.patchPhone(contactDataId, data, params)
  ),

  phonesApiPostMaskPhone: (data: IMaskedPhoneRq) => new ContactDataApi(config).phones.postMaskPhone(data, params),
});

A custom hook gives us control over the API using this simple function, which essentially takes the basic configuration of the client through the hook and creates a map of methods from which the hook extracts the one needed. And all that remains is to call him when we need it.

So we wrote this function by hand. It's time to fix this with custom templates.

First of all, let’s add the path to the downloaded templates to the code generation command:

{
  "swagger:generate-contact-data": "sta --path swagger/contact-data.yaml --output src/services/contact-data/ --api-class-name ContactDataApi --responses --type-prefix I --templates templates/default",
}

Now that the generator sees our templates, let's take a look at them:

This is a set of templates that we downloaded from swagger-typescript-api repository. Each template is responsible for its own part of the code. We can change them and even add our own subtemplates to them using the directive includeFile(pathToTemplate, payload). If we can connect as many subtemplates to the main templates as we want, then the generator expects certain main templates:

  • api.ejs – responsible for the API class;

  • data-contracts.ejs – responsible for types;

  • http-client.ejs – responsible for the http client;

  • procedure-call.ejs – is responsible for methods within the API class;

  • route-docs.ejs – responsible for JSDOC for each method within the API class;

  • route-name.ejs – is responsible for the name of the method inside the API class;

  • route-type.ejs – (`–route-types option`) is responsible for the method type within the API class;

  • data-contract-jsdoc.ejs – responsible for JSDOC for types.

Accordingly, to change the http client, you need to add its template to the folder that you specify when executing the command, otherwise the generator uses the default template.

I needed to modify the template api.ejs. I have added the following entry:

export const get<%~ config.apiClassName %> = (config: ApiConfig, params: RequestParams) => ({
  <% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %>
    <% for (const route of combinedRoutes) { %>

    <%~ includeFile('./procedure-call-getter.ejs', { ...it, moduleName, route }) %>

    <% } %>
  <% } %>

I'm not a guru on ejs templates and wrote this by analogy with existing code. So, I think anyone can figure it out if they want.

As you can see, I added my subtemplate procedure-call-getter.ejs. It is made by analogy with procedure-call.ejs. This is what I indicated there:

<%
const { utils, route, config, moduleName } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils;
const { parameters, path, method, payload, query, formData, security, requestParams } = route.request;
const { type, errorType, contentTypes } = route.response;
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
const queryName = (query && query.name) || "query";
const pathParams = _.values(parameters);
const pathParamsNames = _.map(pathParams, "name");

const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;

const requestConfigParam = {
    name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES),
    optional: true,
    type: "RequestParams",
    defaultValue: "{}",
}

const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
const argToName = ({ name }) => name;

const rawWrapperArgs = config.extractRequestParams ?
    _.compact([
        requestParams && {
          name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
          optional: false,
          type: getInlineParseContent(requestParams),
        },
        ...(!requestParams ? pathParams : []),
        payload,
    ]) :
    _.compact([
        ...pathParams,
        query,
        payload,
    ])

const wrapperArgs = _
    // Sort by optionality
    .sortBy(rawWrapperArgs, [o => o.optional])
    .map(argToTmpl)
    .join(', ')

const wrapperArgsNames = rawWrapperArgs
    .map(argToName)
    .join(', ')

const describeReturnType = () => {
    if (!config.toJS) return "";

    switch(config.httpClientType) {
        case HTTP_CLIENT.AXIOS: {
          return `Promise<AxiosResponse<${type}>>`
        }
        default: {
          return `Promise<HttpResponse<${type}, ${errorType}>`
        }
    }
}

const capitalizeFirstLetter = (string) => {
    return string.charAt(0).toUpperCase() + string.slice(1);
}

const unCapitalizeFirstLetter = (string) => {
    return string.charAt(0).toLowerCase() + string.slice(1);
}

%>

<%~ unCapitalizeFirstLetter(config.apiClassName) %><%~ capitalizeFirstLetter(route.routeName.usage) %>: (<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %>  => {
    return new <%~ config.apiClassName %>(config).<%~ moduleName %>.<%~ route.routeName.usage %>(
        <% if (wrapperArgs.length) { %> <%~ wrapperArgsNames %>, <% } %>
        params,
    );
},

Most of it copied from procedure-call.ejsand I only added:

const capitalizeFirstLetter = (string) => {
    return string.charAt(0).toUpperCase() + string.slice(1);
}

const unCapitalizeFirstLetter = (string) => {
    return string.charAt(0).toLowerCase() + string.slice(1);
}

%>

<%~ unCapitalizeFirstLetter(config.apiClassName) %><%~ capitalizeFirstLetter(route.routeName.usage) %>: (<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %>  => {
    return new <%~ config.apiClassName %>(config).<%~ moduleName %>.<%~ route.routeName.usage %>(
        <% if (wrapperArgs.length) { %> <%~ wrapperArgsNames %>, <% } %>
        params,
    );
},

And now the function I need is also generated:

And now a bonus – connecting a custom config. In the previous article I wrote that the option --type-prefix or --type-suffix applies to types, interfaces, and even enams, which doesn’t suit me personally, since I like to separate them by using different prefixes and suffixes. I also wrote that this problem can be solved using custom templates. Unfortunately, I was wrong.

I was looking for alternative ways to solve this problem, but unfortunately there are none. Perhaps I will contribute to this project later, since I have already thoroughly understood how it works. And it is possible to implement this functionality.

And while I was figuring this out, I learned how to use a custom configuration for code generation. This can be useful if you need, for example, to replace types for dates or something else.

The documentation describes well what can be done using a custom config, but there are no examples of how to use it using a flag --custom-config. And it turns out that it will insert into dead end some users.

It's simple! It is enough to pass the path to the file with your config through this option:

{
  "swagger:generate-contact-data": "sta --path swagger/contact-data.yaml --output src/services/contact-data/ --api-class-name ContactDataApi --responses --type-prefix I --templates templates/default --custom-config generator.config.js"
}

And here is an example of the config:

module.exports = {
  hooks: {
    onPreBuildRoutePath: (routePath) => void 0,
    onBuildRoutePath: (routeData) => void 0,
    onInsertPathParam: (pathParam) => void 0,
    onCreateComponent: (schema) => schema,
    onPreParseSchema: (originalSchema, typeName, schemaType) => void 0,
    onParseSchema: (originalSchema, parsedSchema) => parsedSchema,
    onCreateRoute: (routeData) => routeData,
    onInit: (config, codeGenProcess) => config,
    onPrepareConfig: (apiConfig) => apiConfig,
    onCreateRequestParams: (rawType) => {},
    onCreateRouteName: () => {},
  onFormatTypeName: (typeName, rawTypeName, schemaType) => {},
    onFormatRouteName: (routeInfo, templateRouteName) => {},
  },
  codeGenConstructs: (struct) => ({
    Keyword: {
      Number: 'number',
      String: 'string',
      Boolean: 'boolean',
      Any: 'any',
      Void: 'void',
      Unknown: 'unknown',
      Null: 'null',
      Undefined: 'undefined',
      Object: 'object',
      File: 'File',
      Date: 'Date',
      Type: 'type',
      Enum: 'enum',
      Interface: 'interface',
      Array: 'Array',
      Record: 'Record',
      Intersection: '&',
      Union: '|',
    },
    CodeGenKeyword: {
      UtilRequiredKeys: 'UtilRequiredKeys',
    },
    /**
     * $A[] or Array<$A>
     */
    ArrayType: (content) => {
      if (this.anotherArrayType) {
        return `Array<${content}>`;
      }

      return `(${content})[]`;
    },
    /**
     * "$A"
     */
    StringValue: (content) => `"${content}"`,
    /**
     * $A
     */
    BooleanValue: (content) => `${content}`,
    /**
     * $A
     */
    NumberValue: (content) => `${content}`,
    /**
     * $A
     */
    NullValue: (content) => content,
    /**
     * $A1 | $A2
     */
    UnionType: (contents) => _.join(_.uniq(contents), ` | `),
    /**
     * ($A1)
     */
    ExpressionGroup: (content) => (content ? `(${content})` : ''),
    /**
     * $A1 & $A2
     */
    IntersectionType: (contents) => _.join(_.uniq(contents), ` & `),
    /**
     * Record<$A1, $A2>
     */
    RecordType: (key, value) => `Record<${key}, ${value}>`,
    /**
     * readonly $key?:$value
     */
    TypeField: ({ readonly, key, optional, value }) =>
      _.compact([readonly && 'readonly ', key, optional && '?', ': ', value]).join(''),
    /**
     * [key: $A1]: $A2
     */
    InterfaceDynamicField: (key, value) => `[key: ${key}]: ${value}`,
    /**
     * $A1 = $A2
     */
    EnumField: (key, value) => `${key} = ${value}`,
    /**
     * $A0.key = $A0.value,
     * $A1.key = $A1.value,
     * $AN.key = $AN.value,
     */
    EnumFieldsWrapper: (contents) => _.map(contents, ({ key, value }) => `  ${key} = ${value}`).join(',\n'),
    /**
     * {\n $A \n}
     */
    ObjectWrapper: (content) => `{\n${content}\n}`,
    /**
     * /** $A *\/
     */
    MultilineComment: (contents, formatFn) =>
      [
        ...(contents.length === 1
          ? [`/** ${contents[0]} */`]
          : ['/**', ...contents.map((content) => ` * ${content}`), ' */']),
      ].map((part) => `${formatFn ? formatFn(part) : part}\n`),
    /**
     * $A1<...$A2.join(,)>
     */
    TypeWithGeneric: (typeName, genericArgs) => {
      return `${typeName}${genericArgs.length ? `<${genericArgs.join(',')}>` : ''}`;
    },
  }),
};

Thank you all for your attention!

PS If you are interested in what kind of custom hook we use for requests, write in the comments. There really is something to see there. This hook uses the full power of Typescript Generics.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *