How-to › Ship an SDK

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.

Audience
API producer
Level
advanced
Topic
Choose a generator and generate an SDK
Languages
TypeScript and JavaScript
Verified

Your API is described by one OpenAPI document per service, and the generator turns each document into its own client. A caller who places an order and then takes a payment installs two packages, configures two clients, and pins two version numbers that move on different days. Both documents declare a schema named Error, each with its own fields.

What you get

You will end up with one package generated from two service documents, each schema named for the service that owns it, and a test that fails when a name moves. This is for you if several teams describe one API and you publish one SDK.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Kiota, one client per descriptionServices release on their own schedules and most callers use one of themA facade you write and version yourself, two kiota-lock.json files to update, and a runtime dependency per languageCallers want one package, one changelog, and one version to pin
Redocly join, then OpenAPI GeneratorYou own every document and want one coherent package with one version numberA join step on a command Redocly marks experimental, a prefix on every component, and a full regeneration when any one service changesA service you do not own changes its document without telling you
Speakeasy source with several inputsYou generate with Speakeasy already and want the merge inside its workflow fileAn account and a vendor-owned generator in the release path, and last-wins merging unless every input carries a namespaceYou 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 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:

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 ?? ''}` }
  }
}
node join.mjs
@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.

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:

node surface.mjs
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 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.

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

node --test join.test.mjs
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.

node pitfall.mjs
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 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.

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.

Footnotes

  1. The grammar has not moved across the 3.x line. OpenAPI 3.0.3, the version these documents declare, and 3.2.1 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 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. ↩︎ Back to text

  2. x-tagGroups is a Redoc extension, and Redocly’s page on it 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. ↩︎ Back to text

  3. The example in the join command’s documentation 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.