Skip to content

kekyo/modesta

Repository files navigation

modesta

Simplest zero-dependency Swagger/OpenAPI --> TypeScript proxy generator

modesta

Project Status: Active – The project has reached a stable, usable state and is being actively developed. License: MIT npm version


(For Japanese language/ζ—₯本θͺžγ―こけら)

Please note that this English version of the document was machine-translated and then partially edited, so it may contain inaccuracies. We welcome pull requests to correct any errors in the text.

What Is This?

When accessing an API provided via Swagger from TypeScript, it’s only natural to want to automatically generate TypeScript type definitions to ensure type-safe API access. There are several "transformation tools" available to meet this need, and modesta is one of them.

So, what sets modesta apart?

  • It has almost no environment dependencies and is very easy to use.
  • It’s easy to extend to support custom transports.
  • The transformation process can be almost fully automated using a Vite plugin.
  • It has no unnecessary dependencies on external libraries.

For example, given a Swagger file like this (YAML is also supported):

{
  "openapi": "3.0.3",
  "info": {
    "title": "User API",
    "version": "1.0.0"
  },
  "paths": {
    "/users/{id}": {
      "get": {
        "operationId": "GetUser",
        "summary": "Get a user.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "required": ["id", "name"],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          }
        }
      }
    }
  }
}

and running the following command:

modesta swagger.json src/generated/modesta_proxy.ts

you get a proxy file (TypeScript code) like this (partially omitted and simplified):

// This file is auto-generated by modesta.
// Do not edit manually

export interface AccessorSenderInterface { /* ... */ }
export interface CreateFetchSenderOptions { /* ... */ }

export const createFetchSender = (
  options?: CreateFetchSenderOptions | undefined
): AccessorSenderInterface => {
  /* ... (helper implementation is generated here) */
};

export interface User {
  id: string;
  name: string;
}

export interface GetUser_get_arguments {
  id: string;
}

export interface GetUser {
  get: (
    args: GetUser_get_arguments,
    options?: AccessorOptions | undefined
  ) => Promise<User>;
}

export function create_GetUser_accessor(
  sender: AccessorSenderInterface
): GetUser;
/* ... (additional overloads are generated here) */

As you can see, the generated proxy file remains self-contained and adds no external runtime dependency. Using it, you can easily write API calling code like this:

import {
  create_GetUser_accessor,
  createFetchSender,
} from './generated/userApi';

// Prepare a Sender
const sender = createFetchSender();

//  :
//  :

// Access the API (typed)
const userApi = create_GetUser_accessor(sender);
const user = await userApi.get({ id: '42' });

console.log(user.name);

You can either perform this process manually or automate it using a Vite plugin.

Main Features

  • Reads Swagger files (JSON/YAML) and generates TypeScript source code.
  • Concise output and a clear interface minimize obstacles when integrating the code into applications.
  • Zero runtime dependencies; completely standalone code output. Usable in any environment, including browsers and Node.js.
  • Usable both as a CLI and as a library API. Additionally, it can be integrated with HMR using the Vite plugin.
  • Tested with Swashbuckle.AspNetCore and Huma swagger/OpenAPI output.

Installation

Add it to your devDependencies:

npm install -D modesta

Or, you can install CLI command modesta globally:

npm install -g modesta

Or, you can run it directly with npx:

npx modesta swagger.json src/generated/modesta_proxy.ts

Usage

CLI

Run the CLI like this:

modesta
modesta swagger.json
modesta swagger.json src/generated/modesta_proxy.ts
modesta https://example.com/swagger/v1/swagger.json src/generated/modesta_proxy.ts
modesta --sync
  • If the first positional argument is omitted, it reads the Swagger file from stdin.
  • If the first positional argument is present, it reads that file. When the argument is an http/https URL, it fetches the file directly from that site.
    • If --insecure is specified, TLS certificate verification is disabled for remote https inputs.
  • If the second positional argument is omitted, it writes the generated proxy file (TypeScript source code) to stdout.
  • If the second positional argument is present, it writes the proxy file to that path.
  • If --sync is specified on its own, it reads and executes the Vite plugin configuration (see the next section).

Vite Plugin

By using the Vite plugin, you can integrate it into Vite's build lifecycle. A particular advantage is that HMR is automatically applied when the Swagger file is updated:

import { defineConfig } from 'vite';
import modesta from 'modesta/vite';

export default defineConfig({
  plugins: [
    // Add the modesta Vite plugin
    modesta({
      // When this Swagger file is updated, the proxy file is updated too
      source: './swagger.json',
    }),
  ],
});

The options are:

  • source is required and specifies the location of the Swagger file. You can specify a local file path, a file URL, or an http/https URL.
  • insecure disables TLS certificate verification for remote https URLs. It defaults to false.
  • outputPath is the output path for the proxy file. If omitted, src/generated/modesta_proxy.ts is used.
  • If the input file is located on the local file system, the proxy file is generated when the Vite plugin starts, and subsequent changes are monitored and updated.
  • If the input file is a URL, the plugin does not automatically update it. Instead, use modesta --sync to synchronize explicitly.

When the input file is a URL that points to a remote host, the plugin cannot watch it for changes. In that case, you can manually update the proxy file with the modesta --sync command.

For example, configure the Vite plugin to reference a remote Swagger file:

export default defineConfig({
  plugins: [
    modesta({
      // Reference Swagger published from a remote host
      source: 'https://example.com/api/v2/swagger.json',
    }),
  ],
});

and add a scripts entry like this to package.json:

{
  "scripts": {
    "dev": "vite dev",
    "sync": "modesta --sync"
  }
}

Then npm run sync can update the proxy file and keep the update process simple.


Generated Code Usage Example

Generated code includes each accessor interface and factory function. Import and use those definitions from the generated file.

  1. First, create a "Sender object". It works as the transport used to access the remote API. In most cases, createFetchSender() is enough. Internally, this function uses the fetch API to access the remote API. You can adjust the URL used for actual access by specifying baseUrl. For detailed options, please refer to the next section.
  2. Pass the Sender object to each accessor factory function to create an accessor interface instance.
  3. The accessor interface defines a TypeScript representation of the remote API, so API access is completed by calling those functions.

Here is a simple example:

import {
  create_ListSummaries_accessor,
  createFetchSender,
} from './generated/accessors';

// 1. Create a Sender object that uses the fetch API with an explicit base URL and authentication token
//    If the destination is the same, you can create it once and reuse it
const sender = createFetchSender({
  baseUrl: 'https://example.com',
  headers: {
    authorization: 'Bearer token',
  },
});

//  :
//  :

// 2. Generate an accessor for the API from the Sender
const summaries = create_ListSummaries_accessor(sender);

// 3. Call the API (you can also specify an AbortSignal)
const result = await summaries.get({
  region: 'apac',
  queryParameters: {
    limit: 20,
  },
  headerParameters: {
    'x-api-key': 'secret',
  },
}, { signal });

As in summaries.get({ ... }), you can pass the parameters that should be sent to the API as the argument. These parameters are type-safe because they are defined as TypeScript types converted from the Swagger file.

You can also pass an AbortSignal as shown above to request cancellation of the API call.

Base URL Resolution

The generated accessor treats the paths listed in Swagger as relative paths from the "base URL". Typically, Swagger paths are listed as absolute paths, such as /api/v2/foobar. This is interpreted as api/v2/foobar.

This relative path is then concatenated with the base URL to determine the endpoint URL. The base URL is determined in the following order:

  1. baseUrl specified as an argument to createFetchSender() or modestaPrepareRequest() (described later)
  2. baseUrlSource: β€˜origin’: Always globalThis.location.origin
  3. baseUrlSource: β€˜swagger’: Swagger’s servers[0].url
  4. baseUrlSource: β€˜auto’ or when omitted: If servers[0].url exists, use that; otherwise, use globalThis.location.origin

Typically, the initialization code looks like this:

// Automatically determines the base URL
const sender = createFetchSender();

// Explicitly specify the base URL
const sender = createFetchSender({
  baseUrl: 'https://baz.example.com/',
});

// Always use the browser's origin as the base URL
const sender = createFetchSender({
  baseUrlSource: 'origin'.
});

Note that baseUrl and baseUrlSource cannot be specified simultaneously.

For example, if the Swagger path is /api/v2/foobar and the base URL of the public endpoint is https://baz.example.com/foobar_svc, specify it as follows:

const sender = createFetchSender({
  baseUrl: β€˜https://baz.example.com/foobar_svc’,
});

In this case, the request URL will be https://baz.example.com/foobar_svc/api/v2/foobar.


Proxy Code Generation Rules

modesta outputs proxy code as TypeScript code. This is designed for IDE environments that can reference type definitions on the fly. Even without knowing the detailed code generation rules, IDE assistance should let you write API calling code smoothly.

The following sections briefly describe the generation rules.

Naming Rules

modesta derives public names from either operationId or the URL path.

  • If operationId exists: The accessor interface name is derived from the normalized operationId, and the method name becomes the HTTP method such as get or post
  • If operationId does not exist: It groups accessors by literal URL path segments and derives the method name from the remaining path plus the HTTP method
  • Path parameters are reflected in method names in the form by_<name>
  • Characters that cannot be used in identifiers are normalized to _, and reserved words are escaped

For example, GET /users/{id} with no operationId becomes roughly the following proxy code (partially omitted and simplified):

export interface users {
  get_by_id: (
    args: users_get_by_id_arguments,
    options?: AccessorOptions | undefined
  ) => Promise<User>;
}

export function create_users_accessor<TAccessorContext>(
  sender: AccessorSenderInterface | AccessorSenderInterfaceWithContext<TAccessorContext>
): users | users_with_context<TAccessorContext> {
  return {
    get_by_id: async (args, options) =>
    sender.send(
      {
        operationName: 'users.get_by_id',
        method: 'GET',
        url: modestaBuildUrl(
          '/users/{id}',
          //  :
          //  :
        ),
        //  :
        //  :
      },
      undefined,
      options
    ),
  } as users | users_with_context<TAccessorContext>;
}

Type Conversion

Swagger schemas are converted to TypeScript roughly as follows:

  • Schemas with object or properties become interface
  • array becomes Array<...>
  • enum becomes a literal union
  • nullable: true becomes | null
  • Successful responses without content become void
  • additionalProperties becomes an index signature
  • allOf is flattened into an object when possible, but reusable schema references are kept as intersections instead of being expanded inline

Generated files start with a header like this:

// @ts-nocheck
// This file is auto-generated by modesta.
// Do not edit manually

@ts-nocheck keeps generated proxy code from being affected by your project's code formatting and code checking rules.

Comment Reflection

  • Swagger summary and description are reflected into accessor method JSDoc comments
  • Schema and property description fields are also reflected into generated type definitions
  • Swagger deprecated: true is reflected into generated JSDoc as @deprecated
  • In other words, if your Swagger document already carries comments or annotations, modesta can use them as-is

Limitations

  • Schema compositions containing oneOf, anyOf, or discriminator are not supported.
  • Only local $ref references are supported.
  • Operations without any successful response (2xx) cannot be generated.
  • Only path, query, and header parameter locations are supported.
  • A path with no literal segment cannot be named unless operationId is present.
  • Name collisions after normalization result in an error.

Custom Sender Objects and Context Values (Advanced Topic)

In some cases, you may want to use another transport implementation instead of the fetch API. Examples include libraries such as axios, or transports such as WebSocket.

With modesta, you can provide a Sender object to use any transport layer.

You can also define a custom Sender object to pass a "context value", an out-of-band value used during API calls. Context values can be used to add values that are not specified by Swagger, such as request headers or flags that adjust send/receive behavior.

The following example uses axios as the transport and requires an additional parameter set for every API call:

import axios from 'axios';
import {
  create_ListSummaries_accessor,
  modestaDefaultSerializers,
  modestaDeserializeResponsePayload,
  modestaPrepareRequest,
  modestaProjectResponse,
  modestaSerializeRequestValue,
  type AccessorSenderInterfaceWithContext,
} from './generated/accessors';

// A context value passed for each accessor function call
interface MyApiContext {
  baseUrl: string;    // Allows a different base URL for each API call
  authToken: string;  // Authentication token
  requestId: string;  // Request ID for each API call
}

// Define a Sender factory that uses a custom transport layer
// MyApiContext is used as the context value
const createMyCustomSender = (): AccessorSenderInterfaceWithContext<MyApiContext> => {
  const serializers = modestaDefaultSerializers;

  return {
    // Send function
    send: async (request, requestValue, accessorOptions) => {
      // Collect request information
      const preparedRequest = modestaPrepareRequest(request, accessorOptions, {
        baseUrl: accessorOptions.context.baseUrl,  // Base URL
        headers: {
          authorization: `Bearer ${accessorOptions.context.authToken}`,  // Authentication token
        },
      });

      // Perform serialization
      const requestPayload = modestaSerializeRequestValue(
        request,
        requestValue,
        serializers
      );

      // axios: Execute the call
      const response = await axios.request({
        url: preparedRequest.url.href,
        method: preparedRequest.method,
        headers: {
          ...preparedRequest.headers,
          'x-request-id': accessorOptions.context.requestId,  // Insert request ID
        },
        data: requestPayload,
        responseType: 'text',
        signal: preparedRequest.signal,
        // Keep response payloads raw so serializers can deserialize them.
        transformResponse: [(data) => data],
      });

      const getHeader = (name: string) => {
        const value = response.headers[name.toLowerCase()];
        // axios: If the value is an array, concatenate the elements separated by commas;
        // otherwise, treat it as a string.
        return Array.isArray(value)
          ? value.join(', ')
          : value == null
            ? null
            : String(value);
      };

      // Perform deserialization
      const responseValue = modestaDeserializeResponsePayload(
        { getHeader },
        response.data,
        request.responseContentType,
        serializers,
        request.responseBodyMetadata
      );

      // Build the result value
      return modestaProjectResponse(request, { getHeader }, responseValue);
    },
  };
};

Once you've defined the Sender factory, you can use it to generate and use accessors:

// Create the custom Sender
const sender = createMyCustomSender();

//  :
//  :

// Generate an accessor for the API by specifying the custom Sender
const summaries = create_ListSummaries_accessor(sender);

// Invoke the accessor method
const result = await summaries.get(
  // Parameters defined by Swagger
  {
    region: 'apac',
  },
  {
    // MyApiContext must be provided for each accessor method call
    context: {
      baseUrl,
      authToken: bearerToken,
      requestId: 'request-99',
    },
  }
);
  • A Sender factory that returns AccessorSenderInterfaceWithContext<TContext> uses TContext as the context type and can force API calls to specify that context.
  • A Sender factory that returns AccessorSenderInterface does not require an additional context value. API calls do not need to specify context either. createFetchSender() returns this interface type, so API calls do not need to specify a context value.
  • Sender implementations receive the request value as the second send argument. Serialize it inside the Sender factory with modestaSerializeRequestValue(request, requestValue, serializers).
  • Sender factories are also responsible for response deserialization. For non-fetch transports, read the raw response payload and pass it to modestaDeserializeResponsePayload(), then pass the deserialized value to modestaProjectResponse(). The raw payload is passed to the selected serializer as-is, so the serializer decides how to handle values such as undefined. If you already have a fetch-compatible Response, use modestaReadFetchResponseValue(response, request.responseContentType, serializers, request.responseBodyMetadata) before modestaProjectResponse().

Custom Serializers (Advanced Topic)

modesta provides helper definitions for conversion mappings and custom serializers so values generated from Swagger can be converted and restored with your own rules.

For example, when Swagger emits a format value such as date-time, the default generated code treats it as a plain string. By defining a type mapping and a custom serializer, you can automatically convert such Swagger definitions to Date objects.

Type Mapping

To change the generated TypeScript type, specify formatTypeMappings during code generation. The following example shows the Vite plugin configuration:

import { defineConfig } from 'vite';
import modesta from 'modesta/vite';

export default defineConfig({
  plugins: [
    modesta({
      source: './swagger.json',
      // Map OpenAPI format values to TypeScript type expressions.
      formatTypeMappings: {
        'date-time': 'Date',
      },
    }),
  ],
});

With this setting, a field declared as type: "string", format: "date-time" is emitted as Date in the generated TypeScript type. For example, when using dayjs, you can specify a type expression such as 'import("dayjs").Dayjs'.

Note: formatTypeMappings only changes generated TypeScript types. It does not perform runtime conversion. Therefore, if you emit date-time as Date, your custom serializer must convert values with the same rule. See the next section.

Runtime Conversion

You can implement runtime conversion from scratch, but for JSON payloads (application/json), createCustomJsonSerializer() provides a simpler way:

import {
  createFetchSender,
  createCustomJsonSerializer,
} from './generated/accessors';

// Create a custom JSON serializer.
const jsonSerializer = createCustomJsonSerializer({
  // Convert TypeScript/JavaScript values to JSON values.
  trySerialize: (value, format, ref) => {
    // If the Swagger format is date-time and the value is a Date object:
    if (format === 'date-time' && value instanceof Date) {
      // Convert it to an ISO string.
      ref.result = value.toISOString();
      return true;
    }
    // Otherwise, use the normal conversion path.
    return false;
  },
  // Convert JSON values to TypeScript/JavaScript values.
  tryDeserialize: (value, format, ref) => {
    // If the Swagger format is date-time and the payload value is a string:
    if (format === 'date-time' && typeof value === 'string') {
      // Create a Date object from the string.
      ref.result = new Date(value);
      return true;
    }
    // Otherwise, use the normal conversion path.
    return false;
  },
});

// Create a sender with the custom serializer.
const sender = createFetchSender({
  // Map application/json to the custom serializer.
  serializers: new Map([
    ['application/json', jsonSerializer],
  ]),
});

Library Usage (Advanced Topic)

When using modesta as a library, use public APIs such as loadOpenApiDocumentFromFile, generateAccessorSourceFromFile, and generateAccessorSource. The following is an example of generateAccessorSource:

import {
  generateAccessorSource,
  generateAccessorSourceFromFile,
} from 'modesta';

// Enter a Swagger file to generate proxy code
const source = generateAccessorSource({
  document: openApiText,
  source: 'swagger.yaml',
});

const generatedFromRemote = await generateAccessorSourceFromFile({
  source: 'https://example.com/swagger/v1/swagger.json',
});

Notes

There are many tools, including official ones, that convert Swagger into TypeScript. But I was not very satisfied with them because:

  • Their runtime requirements and assumptions are too complicated
  • They expose too many options

So I made this. It is tuned to provide only the necessary functionality while staying intentionally modest(a).

Pull Requests

Pull requests are welcome! Please submit them as diffs against the develop branch and squashed changes before send.

License

Under MIT.

About

Simplest zero-dependency Swagger/OpenAPI --> TypeScript proxy generator 🐣

Topics

Resources

License

Stars

Watchers

Forks

Contributors