openapi-to-k6
@spartaficial/openapi-to-k6 generates a typed HTTP client for k6 load tests from an OpenAPI 3.x specification: a types file, a client that builds and sends each request the way the specification describes, and an optional service that checks status codes and parses bodies. This page is for developers writing k6 scripts in TypeScript, and it covers both ways of running the generator, every option, and how the generated code fits into a k6 bundle.
What it generates
Take an API, simple-api.yaml, with a few operations:
openapi: 3.0.3
info:
title: Test API
version: 1.0.0
paths:
/users:
get:
operationId: listUsers
summary: List all users
parameters:
- name: page
in: query
schema: { type: integer, default: 1 }
- name: limit
in: query
schema: { type: integer, default: 10 }
- name: X-Request-ID
in: header
schema: { type: string }
responses:
"200":
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/User"
total: { type: integer }
post:
operationId: createUser
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateUserRequest"
responses:
"201":
description: User created
content:
application/json:
schema:
$ref: "#/components/schemas/User"
/users/{userId}:
get:
operationId: getUserById
parameters:
- name: userId
in: path
required: true
schema: { type: string }
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
required: [id, name, email]
properties:
id: { type: string }
name: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [admin, user, guest] }
CreateUserRequest:
type: object
required: [name, email]
properties:
name: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [admin, user, guest] }
and a configuration asking for all three files:
// openapi-to-k6.config.ts
import { defineConfig } from "@spartaficial/openapi-to-k6";
export default defineConfig({
specs: [
{
input: "simple-api.yaml",
outputTypes: "k6/api/types.ts",
outputClient: "k6/api/client.ts",
outputService: "k6/api/service.ts",
},
],
});
npx openapi-to-k6 writes the three files. The types file holds the schema types and the parameter types the service methods take (trimmed here to the GET /users operation):
// k6/api/types.ts
export type User = {
id: string;
name: string;
/** @format email */
email: string;
role?: "admin" | "user" | "guest";
};
export type CreateUserRequest = {
name: string;
/** @format email */
email: string;
role?: "admin" | "user" | "guest";
};
export type GetUsersParams = {
/** Page number */
page?: number;
/** Items per page */
limit?: number;
};
export type GetUsersHeaders = {
/** Request tracking ID */
"X-Request-ID"?: string;
};
The client is a passthrough over k6/http that returns the raw Response:
// k6/api/client.ts
import http from "k6/http";
import type { Params, Response, RequestBody } from "k6/http";
import {
buildQueryString,
cleanBaseUrl,
mergeRequestParameters,
type QueryParams,
} from "@spartaficial/openapi-to-k6/runtime";
export class Client {
private readonly baseUrl: string;
private readonly commonRequestParameters: Params;
constructor(baseUrl: string, commonRequestParameters: Params = {}) {
this.baseUrl = cleanBaseUrl(baseUrl);
this.commonRequestParameters = commonRequestParameters;
}
/**
* @summary List all users
* @method GET /users
*/
getUsers(options?: { params?: QueryParams; requestParameters?: Params }): Response {
const url = this.baseUrl + `/users` + buildQueryString(options?.params);
const mergedParams = mergeRequestParameters(options?.requestParameters || {}, this.commonRequestParameters);
return http.request("GET", url, null, {
...mergedParams,
headers: {
...mergedParams?.headers,
},
});
}
postUsers(options?: { requestParameters?: Params; body?: RequestBody | null }): Response {
const url = this.baseUrl + `/users`;
const mergedParams = mergeRequestParameters(options?.requestParameters || {}, this.commonRequestParameters);
return http.request("POST", url, options?.body, {
...mergedParams,
headers: {
"Content-Type": "application/json",
...mergedParams?.headers,
},
});
}
getUsersByUserId(userId: string, options?: { requestParameters?: Params }): Response {
const url = this.baseUrl + `/users/${encodeURIComponent(String(userId))}`;
// ...
}
}
The service wraps the client with typed parameters, a check() on the status code and a parsed body:
// k6/api/service.ts
import { check } from "k6";
import type { Params } from "k6/http";
import { type K6ServiceResult, serializeBody, formatResponseBody } from "@spartaficial/openapi-to-k6/runtime";
import { Client } from "./client";
import type { GetUsersParams, GetUsersHeaders, User, CreateUserRequest } from "./types";
export class Service {
private readonly _client: Client;
constructor(client: Client) {
this._client = client;
}
getUsers(
headers?: GetUsersHeaders,
params?: GetUsersParams,
requestParameters?: Params
): K6ServiceResult<Record<string, unknown>> {
// Merge headers into request parameters
if (!requestParameters) {
requestParameters = {};
}
requestParameters.headers = { ...requestParameters.headers, ...headers };
const response = this._client.getUsers({ params, requestParameters });
// Validate status code
const ok = check(response, {
"getUsers status is 200": r => {
if (r.status !== 200) {
console.log(`getUsers failed with status: ${r.status}, body: ${formatResponseBody(r.body)}`);
}
return r.status === 200;
},
});
const data = response.json() as Record<string, unknown>;
return { response, data, ok };
}
postUsers(body: CreateUserRequest, requestParameters?: Params): K6ServiceResult<User> {
const response = this._client.postUsers({ requestParameters, body: serializeBody(body, "application/json") });
const ok = check(response, {
"postUsers status is 201": r => {
if (r.status !== 201) {
console.log(`postUsers failed with status: ${r.status}, body: ${formatResponseBody(r.body)}`);
}
return r.status === 201;
},
});
const data = response.json() as User;
return { response, data, ok };
}
}
Method names come from the HTTP method and path (getUsers, postUsers, getUsersByUserId) because useOperationId defaults to false for this generator; with useOperationId: true they would be listUsers, createUser and getUserById. The check() names appear in k6's summary and thresholds, the failing body is logged so a load-test failure can be diagnosed from the console, and data is typed with the declared response. The generator prints a summary of paths and operations after each file unless showStats is false.
Install
npm install --save-dev @spartaficial/openapi-to-k6 @types/k6
npx openapi-to-k6 init
npx openapi-to-k6
@types/k6 (0.52.0 or later) is a peer dependency: the generated files import Params, Response and RequestBody from k6/http. k6 itself is not an npm package; install it from its own distribution. You also need a bundler, for the reason given under The runtime module and bundling. Node.js 20 or later is required for the generator; see requirements.md.
init asks for the specification, the client path (k6/api-client.ts), the types path (k6/api-types.ts), whether to use operationId for method names, and the configuration format; it writes a defaults block with includeDescriptions, showStats and preferredContentTypes set to their defaults. Give both output paths: outputTypes is required for this generator. Generation needs a licence key in SPARTAFICIAL_LICENSE_KEY; running the generated k6 scripts, including the import of the package's runtime module, never does. See licensing.md.
Config mode and direct mode
Like the other generators, openapi-to-k6 reads openapi-to-k6.config.ts or .json from the current directory, or the file named with --config, and generates every spec in it. It is the only generator that also accepts the essentials on the command line, which is convenient for a one-off run or a script that has no configuration file:
npx openapi-to-k6 --input openapi.yaml --output k6/client.ts --output-types k6/types.ts
npx openapi-to-k6 -i openapi.yaml -o k6/client.ts -t k6/types.ts -s k6/service.ts --use-operation-id --base-path /api/v1
Direct mode is selected as soon as any of --input, --output or --output-types is given, and then all three are required; the command stops with Direct mode requires --input, --output, and --output-types. otherwise.
| Flag | Config equivalent | Meaning |
|---|---|---|
-i, --input <path> | input | The specification file. Required in direct mode. |
-o, --output <path> | outputClient | Where the client goes. Required in direct mode. |
-t, --output-types <path> | outputTypes | Where the types go. Required in direct mode. |
-s, --output-service <path> | outputService | Also generate the service. |
--use-operation-id | useOperationId: true | Name methods after operationId. |
--base-path <path> | basePath | Prefix every request path. |
--no-descriptions | includeDescriptions: false | Leave JSDoc comments out. |
--no-stats | showStats: false | Do not print the generation summary. |
-c, --config <path> | Configuration file for config mode; ignored when direct flags are given. |
Everything else, filters, header handling, content-type preferences and name shaping, needs a configuration file. Direct mode has no configuration file to locate your project from, which matters only for a licence restricted to one project; licensing.md explains. Set the environment variable DEBUG to any value to see stack traces for unexpected errors in this CLI. The full command reference is in cli.md.
Configure
The configuration file is { defaults?, specs: [...], executionMode? }, and every spec names input, outputClient and outputTypes. outputService is optional. The output paths cannot be set in defaults. executionMode defaults to parallel. Discovery and merging are described in configuration-files.md.
import { defineConfig } from "@spartaficial/openapi-to-k6";
export default defineConfig({
defaults: {
includeDescriptions: true,
showStats: true,
preferredContentTypes: ["application/json"],
},
specs: [
{
input: "openapi.yaml",
outputClient: "k6/api/client.ts",
outputTypes: "k6/api/types.ts",
outputService: "k6/api/service.ts",
useOperationId: true,
basePath: "/api/v1",
ignoreHeaders: ["X-Request-ID"],
operationFilters: { includeTags: ["users"], excludeDeprecated: true },
},
],
});
The options that belong to this generator; the full table is options-k6.md.
| Option | Values | Default | What it does |
|---|---|---|---|
outputClient | file path, required | Where the client class is written. | |
outputTypes | file path, required | Where the types are written. The client and service import from it. | |
outputService | file path | none | Also write the service class. See The service. |
useOperationId | boolean | false | Name methods and parameter types after operationId instead of method and path. |
basePath | path prefix | none | Inserted between the base URL and every path, for example /api/v1. |
ignoreHeaders | header names or glob patterns | none | Removes headers from the *Headers types and the service signatures, case-insensitively. |
preferredContentTypes | content types in order of preference | ["application/json"] | Which declared request or response content type a method uses when there are several. |
enumFormat | enum, union, const-object | union | How enum schemas are declared in the types file. |
The shared options behave as in the other generators: includeDescriptions (default true), showStats (default true), showWarnings, fileHeader and batchSize in configuration-files.md; prefix, suffix, stripSchemaPrefix and stripPathPrefix in naming.md; operationFilters in operation-filters.md; defaultNullable in request-and-response-shapes.md; refMappings in specs-and-references.md. schemaType is not accepted: a load-test client both sends and receives, and the service uses one type for the body it sends and the body it parses, so a single direction has no meaning here. See option-parity.md.
The specification must declare at least one schema under components.schemas; a specification with operations but no component schemas fails with No schemas found in OpenAPI spec.
Use the output in a k6 script
Construct one Client with the base URL and the k6 Params every request should carry, wrap it in a Service, and call the service from the default function:
// k6/users.test.ts
import { sleep } from "k6";
import { Client } from "./api/client";
import { Service } from "./api/service";
const client = new Client(__ENV.BASE_URL ?? "https://api.example.com", {
headers: { Authorization: `Bearer ${__ENV.TOKEN}` },
tags: { name: "users" },
timeout: "30s",
});
const service = new Service(client);
export const options = {
vus: 10,
duration: "30s",
thresholds: {
checks: ["rate>0.99"],
},
};
export default function () {
// Typed, status-checked
const { data, ok } = service.getUsers(undefined, { page: 1, limit: 10 });
if (ok) {
console.log(`total users: ${data.total}`);
}
const created = service.postUsers({ name: "Ada", email: "ada@example.com" });
if (created.ok) {
service.getUsersByUserId(created.data.id);
}
// Raw Response when you need it
const raw = client.getUsers({ params: { page: 2 } });
console.log(raw.status);
sleep(1);
}
service.getUsers(headers?, params?, requestParameters?) takes the operation's typed headers first when it declares any (required when a header is required), then the typed query parameters, then per-request k6 Params (a different timeout, extra tags); an operation with a body takes it after the path parameters: postUsers(body, requestParameters?). The result is a K6ServiceResult<T> with response (the k6 Response), data (the parsed body, typed T) and ok (the result of the status check). Because check() records the result, failed status codes show up in the k6 summary and can drive thresholds without any extra code.
Bundle the script with esbuild, webpack or rollup and run the bundle with k6 run; see the next section but one.
The client
The client exists so that every request leaves k6 exactly as the specification describes it, while you keep full control of k6's own Params.
The constructor takes the base URL, trailing slashes removed, and common Params applied to every request. Each method merges its requestParameters over the common ones with mergeRequestParameters, which deep-merges headers, tags and cookies so a per-request tag does not wipe the common ones. Operations with a request body set Content-Type for JSON and form bodies; for multipart/form-data the header is left to k6, which has to append the boundary it generates. Headers you pass yourself always win.
Path parameters are the leading arguments and are percent-encoded, so a value containing / or a space produces a valid URL that addresses the right resource; label and matrix styles are rendered through serializePathParam. Query parameters are passed as params and serialised by buildQueryString, following each parameter's style and explode: the OpenAPI default produces repeated keys (?tags=a&tags=b), explode: false joins with commas, spaceDelimited and pipeDelimited join with their delimiter, deepObject produces filter[status]=open, and allowReserved leaves reserved characters literal. Only parameters that deviate from the default are passed to the serialiser:
const url =
this.baseUrl +
`/users` +
buildQueryString(options?.params, {
ids: { style: "form", explode: false },
codes: { style: "pipeDelimited", explode: false },
});
The body option is typed as k6's RequestBody | null and sent as is; the service is where typed bodies are serialised. null and undefined query values are omitted. Operations are marked @deprecated in JSDoc when the specification says so, and operationFilters leaves out the operations you do not want to load-test; when every operation is filtered out, no client is written and a warning says so.
The service
The service is generated when outputService is set and adds the three things a load test usually repeats by hand: typed parameters, a status check and body parsing.
Each method asserts the status code the specification declares for the operation's success response with k6's check(), under the name "<method> status is <code>", and logs the status and body with console.log when it fails, so a run with failures has something to read in the console. The body is read according to the declared response content type: a JSON type is parsed with response.json() and typed with the declared schema; any other type, such as text/plain or text/csv, is returned as response.body typed string. A 204 returns data as void. Reading by declared type means a text/plain endpoint, or an HTML error page from a proxy under load, no longer throws before ok can be inspected.
getHealth(requestParameters?: Params): K6ServiceResult<string> {
// ...
const data = response.body as string;
return { response, data, ok };
}
Bodies and responses that reference a component schema are typed with it (CreateUserRequest, User, User[]); inline object schemas are typed as Record<string, unknown>. Request bodies are passed through serializeBody(body, contentType): a plain object is JSON.stringify-ed for JSON, passed to k6 unchanged for application/x-www-form-urlencoded and multipart/form-data so k6 encodes it, and strings, ArrayBuffers and k6 file objects are passed through as they are. When a request or response declares several content types, preferredContentTypes decides which one the method is generated for.
Headers declared by the operation become the first parameter, typed <Method>Headers, and are merged into requestParameters.headers before the call; ignoreHeaders removes headers that the common Params already supply, such as Authorization, from that type and from the signature.
The types file
outputTypes receives two groups of declarations. The first is produced by openapi-to-typescript with the same naming options: a type per component schema, with enums in the enumFormat you choose (union by default here), plus the operation types it derives (<Operation>QueryParams, <Operation>HeaderParams, <Operation>Request, <Operation>Response). The second group is this generator's own: <Method>Params, <Method>Headers and, for cookie parameters, <Method>Cookies, named after the client method and used in the service signatures. With useOperationId: true both groups follow the operationId, so ListUsersQueryParams and ListUsersParams sit next to each other; with the default they are ListUsersQueryParams and GetUsersParams. prefix, suffix and stripSchemaPrefix are applied consistently to the types and to the service's imports of them. Everything the types file can contain is described in typescript.md.
The runtime module and bundling
The generated client and service import a handful of helpers from @spartaficial/openapi-to-k6/runtime, a second entry point of the same package:
| Export | Used by | Purpose |
|---|---|---|
buildQueryString(params, serializations?) | client | Builds the ?a=1&b=2 string, honouring style, explode and allowReserved |
cleanBaseUrl(url) | client | Strips trailing slashes |
mergeRequestParameters(request, common) | client | Deep-merges headers, tags and cookies |
serializePathParam(name, value, serialization?) | client | Renders label and matrix path parameters |
serializeBody(body, contentType?) | service | Serialises a body according to its content type |
formatResponseBody(body) | service | Renders a body for a log line ([ArrayBuffer: N bytes] for binary) |
K6ServiceResult<T> | service | The { response, data, ok } result type |
QueryParams, Params, ParameterSerialization, ParameterSerializationMap, PathParameterSerialization | both | Types |
The module uses no Node.js built-ins and no globals that k6's JavaScript runtime lacks (it builds query strings by hand rather than with URLSearchParams), so it is safe to load inside k6. There is nothing extra to install; the package you generated with provides it, and importing it needs no licence.
k6 does not read the exports field of package.json, so it cannot resolve @spartaficial/openapi-to-k6/runtime on its own, and it does not run TypeScript. Both are solved by the same step: bundle the script before running it.
npx esbuild k6/users.test.ts --bundle --platform=neutral --external:k6 --outfile=dist/users.test.js
k6 run dist/users.test.js
Mark k6 and k6/* as external so the bundler leaves k6's own modules to k6; everything else, including the runtime helpers, is inlined into the bundle. The reasoning behind the separate entry point is in runtime-entry-points.md.
Programmatic API
import { OpenApiK6Generator, defineConfig, loadConfig, mergeConfigWithDefaults } from "@spartaficial/openapi-to-k6";
new OpenApiK6Generator({
input: "simple-api.yaml",
outputClient: "k6/api/client.ts",
outputTypes: "k6/api/types.ts",
outputService: "k6/api/service.ts",
useOperationId: true,
}).generate();
// Or drive a configuration file yourself
const config = await loadConfig("./openapi-to-k6.config.ts");
for (const spec of mergeConfigWithDefaults(config)) {
new OpenApiK6Generator(spec).generate();
}
OpenApiK6Generator takes an OpenApiK6GeneratorOptions object, the same keys as one spec entry, and checks in its constructor that input exists and that outputClient and outputTypes are set. generate() writes the types, the client and, when configured, the service, printing each path and the operation counts; it writes nothing and warns when no operation survives the filters. For code rather than files: generateString() returns the client file, generateSchemaTypesString() the schema and operation types, generateParamTypesString() the <Method>Params and <Method>Headers types, and generateServiceString(clientImportPath, typesImportPath) the service file with the two import paths you supply. The licence is checked lazily on first use, as described in licensing.md.
defineConfig(config) returns its argument and gives .ts configuration files type checking. loadConfig(configPath?) finds and validates a configuration file and returns a Promise<K6ConfigFile>; mergeConfigWithDefaults(config) expands defaults into each spec. TypeScriptGenerator is re-exported for callers that want the types alone, and executeBatch and getBatchExitCode run several generators the way the CLI does.
Any failure inside generate() is wrapped in a K6ClientGenerationError, whose message carries the original message and whose context.cause keeps it; it is also thrown directly when outputTypes or outputClient is missing. The classes GeneratorError, ConfigurationError, SpecValidationError, SchemaGenerationError, CircularReferenceError, FileOperationError, ConfigValidationError, CliOptionsError and MissingDependencyError are re-exported from @spartaficial/openapi-core for instanceof checks. Exported types: OpenApiK6GeneratorOptions, K6ConfigFile, OperationFilters, EnumFormat, BatchExecutionSummary and Generator.
Related
- getting-started.md: a first run from an empty project.
- options-k6.md: every option with type and default.
- cli.md: both modes, every flag.
- runtime-entry-points.md: the
/runtimemodule and bundling. - naming.md: method and type names.
- operation-filters.md: choosing which operations to load-test.
- typescript.md: the generator behind the types file.
- troubleshooting.md: when the bundle or the generated client misbehaves.