An open API service indexing awesome lists of open source software.

https://github.com/kekyo/modesta

Simplest zero-dependency Swagger/OpenAPI --> TypeScript proxy generator 🐣
https://github.com/kekyo/modesta

json proxy-generator simplest swagger type-safe typescript vite yaml

Last synced: 5 days ago
JSON representation

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

Awesome Lists containing this project

README

          

# modesta

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

![modesta](./images/modesta-120.png)

[![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![npm version](https://img.shields.io/npm/v/modesta.svg)](https://www.npmjs.com/package/modesta)

---

[(For Japanese language/日本語はこちら)](./README_ja.md)

> 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):

```json
{
"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:

```bash
modesta swagger.json src/generated/modesta_proxy.ts
```

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

```typescript
// 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;
}

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:

```typescript
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](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) and [Huma](https://huma.rocks/) swagger/OpenAPI output.

---

## Installation

Add it to your `devDependencies`:

```bash
npm install -D modesta
```

Or, you can install CLI command `modesta` globally:

```bash
npm install -g modesta
```

Or, you can run it directly with `npx`:

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

## Usage

### CLI

Run the CLI like this:

```bash
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:

```typescript
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:

```typescript
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`:

```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:

```typescript
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:

```typescript
// 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:

```typescript
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_`
- 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):

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

export function create_users_accessor(
sender: AccessorSenderInterface | AccessorSenderInterfaceWithContext
): users | users_with_context {
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;
}
```

### 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:

```typescript
// @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:

```typescript
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 => {
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:

```typescript
// 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` 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:

```typescript
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:

```typescript
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`:

```typescript
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.