# How to generate one SDK from several OpenAPI documents

> Join one document per service under a prefix rule fixed before the first merge, generate one package from the result, and test that no name moves.

Source: https://voxgig.com/howto/one-sdk-from-several-openapi-documents

- Audience: api-producer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Run redocly join over the service documents with --prefix-components-with-info-prop naming a property each document declares for the purpose, then generate the SDK from the joined document with OpenAPI Generator. Every component is prefixed with its service, so two Error schemas become orders_Error and payments_Error and the generator emits one class for each. Fix the property before the first join and never change it, because the generated class names follow it.

---
## You will need

Node 22 or later, two or more OpenAPI 3 documents whose operationIds do not collide, and a Java
runtime for OpenAPI Generator. Verified 2026-10-21 against Node 22.22.0, `@redocly/cli` 2.64.1,
`@openapitools/openapi-generator-cli` 2.41.0, and OpenAPI Generator 7.26.0. Here the orders service
is `orders.json` and the payments service is `payments.json`, two documents for one shop. Each
declares a schema named `Error` with its own fields, and both declare the same `Money`. The sample
joins the two, reads the generated package, and tests the names. It calls neither service.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Kiota, one client per description](https://learn.microsoft.com/en-us/openapi/kiota/using) | Services release on their own schedules and most callers use one of them | A facade you write and version yourself, two `kiota-lock.json` files to update, and a runtime dependency per language | Callers want one package, one changelog, and one version to pin |
| [Redocly join, then OpenAPI Generator](https://redocly.com/docs/cli/commands/join) | You own every document and want one coherent package with one version number | A join step on a command Redocly marks experimental, a prefix on every component, and a full regeneration when any one service changes | A service you do not own changes its document without telling you |
| [Speakeasy source with several inputs](https://www.speakeasy.com/docs/speakeasy-reference/workflow-file) | You generate with Speakeasy already and want the merge inside its workflow file | An account and a vendor-owned generator in the release path, and last-wins merging unless every input carries a namespace | You need generation to run entirely in your own pipeline |

Joining first gives callers the package they asked for, and gives you a build in which any
service's change regenerates everything, so a payments fix ships an orders release too. Kiota's
route keeps each client on its own release train, and the facade that makes two clients read as one
SDK is code you own, outside any generator. Speakeasy folds the merge into its workflow file, with
`modelNamespace` as the prefix rule, in exchange for running your release through its generator.

## Fix the prefix rule in one place

Redocly's join refuses two components that share a name and differ in content. It merges identical
duplicates once, and it writes nothing at all while a conflict stands, so the first join of these
two documents fails on `Error`. The
[`--prefix-components-with-info-prop`](https://redocly.com/docs/cli/commands/join) option resolves
that by prefixing every component with a value read from the `info` object of the document it came
from. Which property to read is the decision this page is about.

The documents here each declare `x-service`, a short lowercase name, for this one purpose. The
`title` is display text: the join turns each space in it into an underscore, but a parenthesis
passes through into a name the component grammar does not allow.[^1] The `version` works until
the next release, which is the pitfall shown below. `join.mjs`
holds the rule as one constant and runs the join:

```js title="join.mjs"
export const INPUTS = ['orders.json', 'payments.json']
export const OUTPUT = 'merged.json'
export const RULE = ['--prefix-components-with-info-prop', 'x-service']

const HERE = fileURLToPath(new URL('.', import.meta.url))
const REDOCLY = fileURLToPath(new URL('./node_modules/@redocly/cli/bin/cli.js', import.meta.url))

/** Run redocly join. Returns what it printed and whether it wrote the file. */
export function join(output = OUTPUT, rule = RULE, inputs = INPUTS) {
  const args = [REDOCLY, 'join', ...inputs, '-o', output, ...rule]
  const options = {
    cwd: HERE,
    encoding: 'utf8',
    stdio: ['ignore', 'pipe', 'pipe'],
    env: { ...process.env, REDOCLY_TELEMETRY: 'off', REDOCLY_SUPPRESS_UPDATE_NOTICE: 'true' },
  }
  try {
    const stdout = execFileSync(process.execPath, args, options)
    return { ok: true, status: 0, output: stdout }
  } catch (err) {
    return { ok: false, status: err.status, output: `${err.stdout ?? ''}${err.stderr ?? ''}` }
  }
}
```

```bash
node join.mjs
```

```text output
@redocly/cli 2.64.1
join orders.json payments.json -o merged.json --prefix-components-with-info-prop x-service

info: Orders 1.4.0, taken from orders.json
tags: orders, payments
operations: 6
  createOrder createPayment getOrder getPayment listOrders refundPayment
schemas, by prefix:
  orders_   Order NewOrder Money Error
  payments_ Payment NewPayment Refund Money Error
Error is two schemas: orders_Error (code, message) and payments_Error (type, title, status, detail)
Money is two schemas as well, orders_Money and payments_Money, identical: true
```

The output has six operations, two tags, and eleven prefixed components. The two `Error` schemas
stay two schemas. `Money` is two schemas as well, which is the price of the option. It prefixes
every component, the ones that never clashed included, so an identical schema in two documents
becomes two classes in the package. The `info` block is the first document's, with its title and
its version, so the joined document calls itself Orders 1.4.0. A package version belongs to the
package rather than to any one service, and the generator has a setting for it.

## Generate one package from the joined document

OpenAPI Generator reads the joined document like any other. The run is recorded in `generate.sh`,
and it needs Java and, the first time, the network, because the npm wrapper downloads the generator
jar pinned in `openapitools.json`.

```bash title="generate.sh"
set -euo pipefail

node join.mjs
npx --yes @openapitools/openapi-generator-cli@2.41.0 generate -g typescript-fetch -i merged.json -o sdk
rm -rf generated
mkdir generated
cp -r sdk/apis sdk/models generated/
cp sdk/.openapi-generator/VERSION generated/VERSION
```

`surface.mjs` reads that output and prints what a caller sees:

```bash
node surface.mjs
```

```text output
openapi-generator 7.26.0, typescript-fetch, one package from merged.json
api classes: 2
  OrdersApi     createOrder getOrder listOrders
  PaymentsApi   createPayment getPayment refundPayment
models: 9
  OrdersError OrdersMoney OrdersNewOrder OrdersOrder PaymentsError PaymentsMoney PaymentsNewPayment PaymentsPayment PaymentsRefund
one error type per service:
  OrdersError    code, message
  PaymentsError  type, title, status, detail?
OrdersMoney and PaymentsMoney carry the same fields: true
```

One package, two `Api` classes. The
[`typescript-fetch` generator](https://openapi-generator.tech/docs/generators/typescript-fetch/)
groups operations by tag, and the tags came through the join unchanged: tags are prefixed only on
request, with `--prefix-tags-with-info-prop`, and these two never clashed. The join also wrote an
`x-tagGroups` block with one group per source document, which the generator ignores.[^2]
`OrdersError` and `PaymentsError` are two interfaces with different fields, which is what the
prefix bought. `OrdersMoney` and `PaymentsMoney` are the same interface twice, which is what it
cost.

## Make a rename fail the build

The names in the package follow the prefix rule. A change to the rule, or to the value it reads, is
a breaking change for every caller even when no path or field changed. The test pins both ends:
the committed `merged.json` must be what the join produces today, and the generated model names
must be the joined schema names camel-cased.

```js title="join.test.mjs"
test('the join reproduces the committed document byte for byte', () => {
  const out = joinPath(dir, 'again.json')
  const result = join(out)
  assert.equal(result.ok, true, result.output)
  assert.equal(readFileSync(out, 'utf8'), readFileSync(new URL(`./${OUTPUT}`, import.meta.url), 'utf8'))
})

test('the generated models are the joined schemas, camel-cased', () => {
  const index = readFileSync(new URL('./generated/models/index.ts', import.meta.url), 'utf8')
  const generated = [...index.matchAll(/from '\.\/(\w+)'/g)].map((m) => m[1]).sort()
  const camel = (name) => name.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join('')
  assert.deepEqual(generated, Object.keys(merged.components.schemas).map(camel).sort())
})
```

Run the join in CI on every change to a service document and let the first test fail when the
output drifts from the committed file; the diff is the review. A service that edits its
`x-service` fails the same test, which is the point of pinning the rule outside any one document.
Switching the rule to `title` or `version` in `join.mjs` fails that test alone, because no other
test joins under the rule.

## Check it worked

```bash
node --test join.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1431.140226
```

`# pass 6` covers the reproduction of the committed document, the generated names, every operation
from both inputs, the two `Error` schemas, the prefix on every component, and the refusal without a
rule.

## When it goes wrong

Two failures are shown by `pitfall.mjs`, which joins into a temporary directory and reads only the
component names back.

```bash
node pitfall.mjs
```

```text output
1. no prefix rule
   exit 1, output file written: false
   Conflict on components => responses : ErrorResponse in files: orders.json,payments.json
   Conflict on components => schemas : Error in files: orders.json,payments.json
   Please fix conflicts before running join.

2. the rule names a property whose value moves
   x-service  orders_Error  payments_Error
   title      Orders_Error  Payments_Error
   version    1.4.0_Error  2.0.1_Error
   payments 2.0.1 to 2.1.0, no path or field changed: 5 of 5 payments schemas renamed under the version rule
```

The first is the join without a rule. Redocly names both conflicts, `ErrorResponse` and `Error`,
exits with status 1, and writes no file, so a build that forgot the option stops at this step.
[Speakeasy's merge](https://www.speakeasy.com/docs/sdks/prep-openapi/merge) takes the opposite
position: with no namespace, the later document's component replaces the earlier one, and a
structural difference is reported as a warning. Under that rule the payments `Error` replaces the
orders `Error` and the build continues.

The second is a rule whose value moves. The option takes any `info` property, and Redocly's own
example uses `version`.[^3] Under that rule the two schemas are `1.4.0_Error` and `2.0.1_Error`,
and a payments release that moves to 2.1.0 renames every payments type in the package without
touching a path or a field. The `title` holds still for longer and breaks the day a service is
renamed in its document. A property declared for the purpose moves only when someone edits it to
move.

The third shows in the published package rather than in the join. The joined `info` is the first
document's, so a package that takes its version from the document ships as 1.4.0 whatever the
payments service is at. The `typescript-fetch` page documents `npmVersion` as defaulting to the
document's version. Pass it, or set the version in the manifest you publish from, and treat the
document's version as the orders service's number.

## When not to do this

Do not join documents you do not own. A team that renames a schema or edits its `x-service` without
telling you breaks your package on their schedule, and that test turns their change into your red
build. Generate a client per document instead and compose the two behind a facade you version
yourself, which is the Kiota route in the table and works with any generator.

Do not prefix with `version`, whatever the documentation's example does. That example joins two
versions of one API, where a name that changes with the version is wanted. Two services are not two
versions.

Do not reach for a join when the services ship on different cadences and most callers use one of
them. One package means one changelog, and a caller of the orders service reads about every payments
change, then upgrades for fixes in calls they never make.

Do not change the rule after the first release. Every prefixed name is public API, and the test
exists so that a change to it is a decision rather than an accident.

## Related how-tos

- [Split and bundle a large OpenAPI document](/howto/split-and-bundle-an-openapi-document)

- [Write operationIds that generate clean SDK method names](/howto/operationids-that-generate-clean-sdk-method-names)

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Tell an entity-shaped SDK from an endpoint-shaped one](/howto/measure-entity-versus-endpoint-shaped-output)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@redocly/cli` 2.64.1, `@openapitools/openapi-generator-cli`
2.41.0, and OpenAPI Generator 7.26.0. Every output block is what the command preceding it printed.
The generator ran once, with the network, through `generate.sh`; its `apis/` and `models/` output
is committed under `generated/` and read by `surface.mjs` and the test. The Speakeasy workflow file
in the sample was written from the workflow reference and the merge guide and was not run. Kiota's
behavior is taken from its documentation. Neither tool was installed.

[^1]: The grammar has not moved across the 3.x line.
[OpenAPI 3.0.3](https://spec.openapis.org/oas/v3.0.3#components-object), the version these
documents declare, and [3.2.1](https://spec.openapis.org/oas/v3.2.1#components-object) both say
that component keys MUST match `^[a-zA-Z0-9\.\-_]+$`, which admits letters, digits, and three
punctuation marks: the dot, the hyphen, and the underscore. Redocly's join meets the grammar
partway. Its
[`getInfoPrefix`](https://github.com/Redocly/redocly-cli/blob/%40redocly/cli%402.64.1/packages/cli/src/commands/join/utils/get-info-prefix.ts)
replaces every whitespace character with an underscore and leaves the rest alone, so a title of
`Orders (EU)` yields `Orders_(EU)_Error`, a key that Redocly's own `lint` rejects. The underscore
the join puts between prefix and name is one of the three.

[^2]: `x-tagGroups` is a Redoc extension, and
[Redocly's page on it](https://redocly.com/docs/realm/content/api-docs/openapi-extensions/x-tag-groups)
says what it is for: a layer of navigation over the tags in rendered reference documentation. The
same page adds that any tag not placed in a group is left out of the output. A joined document
groups every tag by construction, one group per source document named after its `info.title`. A
tag added by hand afterwards is shown nowhere until somebody gives it a group.

[^3]: The example in the [join command's documentation](https://redocly.com/docs/cli/commands/join)
is `redocly join museum_v1.yaml museum_v2.json --prefix-components-with-info-prop version`, which
joins two versions of a museum API into one document. There the version is the prefix that fits,
because the two documents describe the same things at two points in time and the names are meant
to differ by exactly that. The same option applied to two services carries the example's mechanism
into a situation it was not written for.