Skip to content

Repository files navigation

openapi-x-graphql

Convert between OpenAPI and GraphQL, in both directions, and get a working proxy server for free.

Point it at a spec, get a GraphQL API. Every operation becomes a field whose resolver issues the real HTTP request, so there is no glue code to write and nothing to keep in sync. Point it at a GraphQL schema instead and get an OpenAPI document back.

The two converters are inverses: a document converted to GraphQL and back produces the same schema, and a schema converted to OpenAPI and back produces the same SDL. See Round-tripping for exactly what that guarantees.

bunx openapi-x-graphql https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.json
#   openapi-x-graphql  APIs.guru
#     proxying  https://api.apis.guru/v2
#     graphql   http://localhost:4000/graphql
#     fields    7 operations

Open http://localhost:4000/graphql for GraphiQL, with docs and autocomplete generated from the spec.

Install

bun add openapi-x-graphql     # Bun
npm install openapi-x-graphql # Node 20+

Runs on Bun 1.2+ and Node 20+. graphql comes along as a dependency.

YAML specs need a parser. Bun 1.2.21+ has one built in; on Node, install the optional yaml package:

npm install yaml

Without it, JSON specs still work and YAML fails with a message saying exactly this. Everything else — the converters, the server, the CLI — is identical on both runtimes.

Usage

As a server

import { serve } from 'openapi-x-graphql';

await serve({
	source: './openapi.yaml',
	port: 4000,
	auth: { bearerAuth: process.env.API_TOKEN! },
});

source accepts a document object, a file path, an http(s) URL, or raw JSON/YAML text.

As a schema

Get a plain GraphQLSchema and mount it wherever you like — Yoga, Apollo, your own handler:

import { createGraphQLSchema } from 'openapi-x-graphql';

const { schema, operations, warnings } = await createGraphQLSchema('./openapi.yaml', {
	baseUrl: 'https://api.example.com',
	headers: { 'x-api-key': process.env.KEY! },
});

As a handler

createGraphQLHandler returns a plain (Request) => Promise<Response> function, so it runs on Bun, Node, Cloudflare Workers, Deno, or any framework that speaks the web Fetch API:

import { createGraphQLHandler, createGraphQLSchema } from 'openapi-x-graphql';

const { schema } = await createGraphQLSchema('./openapi.yaml');
const handler = createGraphQLHandler(schema, { path: '/graphql' });

Bun.serve({ fetch: handler });

serve() picks the right server for the runtime on its own — Bun.serve under Bun, node:http under Node — so the same code starts a proxy either way. It imports nothing from node: until you call it, which keeps createGraphQLHandler usable on edge runtimes that have no Node built-ins.

The other direction

createOpenApiDocument takes a GraphQLSchema or SDL text and returns an OpenAPI document:

import { createOpenApiDocument } from 'openapi-x-graphql';

const { document, warnings } = createOpenApiDocument(schema, {
	info: { title: 'Pets', version: '2.0.0' },
	baseUrl: 'https://api.example.com',
});

Root fields become operations, object and input types become component schemas, and arguments become parameters or a request body. Nothing has to be running — this is a pure transformation.

CLI

openapi-x-graphql <spec> [options]          start the proxy server
openapi-x-graphql <spec> --print            print the generated SDL and exit
openapi-x-graphql <schema> --to-openapi     print an OpenAPI document built from SDL
openapi-x-graphql <input> --round-trip      convert both ways and report any difference

  -p, --port <number>        port to listen on (default: 4000)
  -H, --host <string>        hostname to bind
  -b, --base-url <url>       override the upstream base URL
  -e, --endpoint <path>      GraphQL endpoint path (default: /graphql)
  -h, --header <k:v>         header sent upstream (repeatable)
      --bearer <token>       shorthand for -h "authorization: Bearer <token>"
      --auth <scheme=value>  credentials for a named securityScheme (repeatable)
      --query-methods <list> methods exposed as queries (default: get,head)
      --no-graphiql          disable the GraphiQL IDE
      --no-cors              do not send CORS headers
      --timeout <ms>         upstream request timeout
      --to-openapi           read GraphQL SDL and print an OpenAPI document as JSON
      --openapi-version <v>  OpenAPI version to emit (default: 3.1.0)
      --round-trip           check that converting both ways gives back the input
  -o, --out <file>           write the output to a file

Check the generated schema into your repo and diff it in CI:

bunx openapi-x-graphql ./openapi.yaml --out schema.graphql

--round-trip figures out which direction it is going from the input, and exits non-zero when the result differs — which makes it usable as a CI gate:

bunx openapi-x-graphql ./schema.graphql --round-trip
#   openapi-x-graphql  round trip
#     direction  graphql -> openapi -> graphql
#     result     identical

How a document maps to a schema

OpenAPI GraphQL
GET / HEAD operation Query field
every other method Mutation field
operationId field name, camelCased
path / query / header / cookie parameter field argument
requestBody an input argument
2xx response schema field return type
components.schemas.X one reusable type X (and XInput when used as a body)
allOf merged into a single object type
oneOf / anyOf a union (outputs) or the JSON scalar (inputs)
discriminator drives resolveType for the union
string enum a GraphQL enum
format: int64 the Long scalar
free-form object, or no schema the JSON scalar
required non-null
deprecated @deprecated
summary / description field and type descriptions

Names are made GraphQL-safe automatically: tag-line becomes tagLine, sold out becomes SOLD_OUT, and the original name is restored on the wire. Collisions get a numeric suffix and a warning. Recursive schemas work — Pet.friends: [Pet!] resolves to the same type object.

A Query._info field reports the document's title, version, and base URL.

How a schema maps to a document

GraphQL OpenAPI
Query field GET /<fieldName>
Mutation field POST /<fieldName>
field name operationId
argument a query parameter, or a path parameter when the path names it
the input argument requestBody
field return type the 200 response schema
object / input object type components.schemas.X
interface a component plus allOf on each implementation
union oneOf
enum a string enum
Int / Float / Long int32 / double / int64
ID, custom scalars type: string plus an x-graphql hint
JSON {}, a schema with no constraints
! required, or x-graphql: { nonNull: true } on a response
@deprecated deprecated: true
description summary / description

A field converted from OpenAPI carries its original method, path, operationId, status code, and parameter locations in an openapi extension, so converting it back reproduces the same operation rather than a synthesized POST /fieldName.

Options

Option Default What it does
info GraphQL API info block for the generated document
servers servers block
baseUrl Shorthand for a one-entry servers block
openapi '3.1.0' Target version; 3.0.x switches to nullable: true
bodyArgumentName 'input' Argument treated as the request body
inputSuffix 'Input' Suffix the reverse conversion appends to input types
enumValueNaming 'screaming-snake' Must match what the reverse conversion uses
pathFor Build the path for a root field
methodFor Choose the HTTP method for a root field
annotate true Write x-graphql hints; false breaks the round trip

The default 3.1 output puts description, deprecated, and x-graphql next to a $ref, which 3.1 allows. Setting openapi: '3.0.3' switches nullability to nullable: true, but those sibling keys remain — strict 3.0 validators ignore them rather than reject the document.

Steering the output

Three vendor extensions override the defaults:

paths:
  /reports:
    post:
      x-graphql-field-name: generateReport # rename the field
      x-graphql-operation-type: query # force Query instead of Mutation
components:
  schemas:
    LegacyThing:
      x-graphql-type-name: Thing # rename the type

Round-tripping

OpenAPI and GraphQL do not describe the same things. OpenAPI has header parameters, media types, and status codes; GraphQL has non-null wrappers, scalar identity, and @deprecated reasons. Converting one to the other therefore has to record what the target language cannot express, or the trip back lands somewhere else.

That record is a single x-graphql extension, written by the GraphQL to OpenAPI converter and read by the OpenAPI to GraphQL converter:

paths:
  /createPet:
    post:
      operationId: createPet
      x-graphql:
        description: Add a pet to the store. # exact GraphQL description, no HTTP marker appended
        bodyArgument: data # the body argument is not called `input`
components:
  schemas:
    DateTime:
      type: string
      x-graphql: { scalar: DateTime } # a scalar, not a plain string
    Casing:
      type: string
      enum: [lowercase, MixedCase]
      x-graphql:
        enumNames: { lowercase: lowercase, MixedCase: MixedCase }

A hint is only written when the value could not be recovered without it, so documents generated from ordinary GraphQL schemas stay readable. annotate: false suppresses them entirely, at the cost of the guarantee below.

What is guaranteed

Direction Guarantee
GraphQL → OpenAPI → GraphQL The SDL is identical
OpenAPI → GraphQL → OpenAPI → GraphQL The two schemas are identical

The second one is stated in terms of schemas rather than documents on purpose. An OpenAPI document carries far more than GraphQL has room for, so the regenerated document is not byte-identical to the original — it has lost the header parameters, the 4xx responses, the media types other than JSON. What must hold, and does, is that the conversion has settled: everything GraphQL can see survives every further trip unchanged.

Both are checkable:

import { roundTripGraphQL, roundTripOpenApi } from 'openapi-x-graphql';

const { matches, differences } = roundTripGraphQL(schema);
const settled = roundTripOpenApi(document);

Each returns the intermediate document, the rebuilt schema, a matches boolean, and a line-level differences list. compareSchemas(a, b) and printStableSchema(schema) are exported for building your own checks.

What does not survive

  • Interfaces. OpenAPI cannot list a type's implementations, so an interface comes back as a plain object type and the types implementing it are dropped. A warning says so.
  • Subscriptions. Skipped, with a warning.
  • Arguments on non-root fields. Only root fields become operations, so these are dropped.
  • Enum value descriptions and deprecations. OpenAPI enums are a bare list of values.
  • Query._info. Synthesized by the OpenAPI to GraphQL direction, never emitted back. roundTripGraphQL asks for it only when the input schema already had one.

Options

Everything below is shared by serve, createGraphQLSchema, and buildGraphQLSchema.

Option Default What it does
baseUrl first servers[].url Upstream origin for every request
headers Static headers merged into every request
auth Credentials keyed by securityScheme name
fetch globalThis.fetch Custom fetch, for mocking or instrumentation
onRequest Inspect or replace each outgoing Request
onResponse Observe each upstream Response
timeoutMs Abort upstream requests after N ms
queryMethods ['get', 'head'] Methods exposed as Query fields
filter Return false to drop an operation
fieldName Override the generated field name
bodyArgumentName 'input' Name of the request-body argument
includeInfoField true Add Query._info
nonNullRequiredFields true Mark required properties non-null
generateEnums true Build GraphQL enums from string enums
enumValueNaming 'screaming-snake' Or 'preserve' to keep valid names as written
inputSuffix 'Input' Suffix for generated input object types

Server-only options: port, hostname, path, graphiql, cors, context, title, banner.

Authentication

auth is keyed by the security scheme name in the document, and the right transport is applied automatically:

await serve({
	source: './openapi.yaml',
	auth: {
		bearerAuth: process.env.TOKEN!, // http/bearer  -> Authorization: Bearer
		apiKeyAuth: process.env.KEY!, // apiKey       -> header, query, or cookie
		basicAuth: { username: 'u', password: process.env.P! }, // http/basic  -> Authorization: Basic
	},
});

When an operation declares security, only the credentials it declares are sent.

For per-request credentials, use context plus onRequest:

await serve({
	source: './openapi.yaml',
	context: (request) => ({ token: request.headers.get('authorization') }),
	onRequest: (upstream, { context }) => {
		const headers = new Headers(upstream.headers);
		headers.set('authorization', (context as { token: string }).token);
		return new Request(upstream, { headers });
	},
});

Errors

A non-2xx upstream response becomes a GraphQL error carrying the details:

{
	"errors": [
		{
			"message": "GET /pets/{petId} failed with 404 Not Found",
			"extensions": {
				"code": "OPENAPI_REQUEST_FAILED",
				"status": 404,
				"operationId": "getPetById",
				"body": { "message": "no such pet" }
			}
		}
	]
}

Limitations

Converting OpenAPI to GraphQL:

  • Local $ref only. External refs (./other.yaml#/X) degrade to the JSON scalar and produce a warning. Bundle your spec first — redocly bundle or swagger-cli bundle — for full typing.
  • No input unions. GraphQL has none, so oneOf/anyOf request bodies become the JSON scalar.
  • JSON and form bodies. multipart/form-data and file uploads are not handled.
  • Swagger 2.0 is partial. host/basePath/schemes and #/definitions/... refs resolve, but in: body parameters do not. Convert to OpenAPI 3 for full support.

Converting GraphQL to OpenAPI:

  • No subscriptions. OpenAPI describes request/response; there is nothing to map.
  • Interfaces lose their implementations, and arguments on non-root fields are dropped. See What does not survive.
  • Paths are synthesized as /<fieldName> unless the field came from an OpenAPI document or pathFor says otherwise. GraphQL carries no notion of a URL.

warnings on either result — printed by the CLI at startup — lists everything that degraded.

Verified against real specs

Spot-checked against large published documents from the APIs.guru directory, converting each one to GraphQL, back to OpenAPI, and to GraphQL again:

Spec Operations Types Round trip
Stripe 446 3,074 identical
GitHub 845 1,204 identical

Reproduce either with the CLI:

bunx openapi-x-graphql https://api.apis.guru/v2/specs/stripe.com/2022-11-15/openapi.json --round-trip

Runtime support

Bun 1.2+ Node 20+
Converters, both directions yes yes
serve() Bun.serve node:http
createGraphQLHandler yes yes
CLI yes yes
JSON specs yes yes
YAML specs built in needs yaml

Four things run in CI on every push:

Suite Runs against Where
bun test src/ Bun
node --test test/node/*.mjs dist/ Node 20, 22, 24
test/smoke.mjs dist/ Bun and Node, same file
Packaged install the packed tarball Node

The smoke script is deliberately framework-free so both runtimes can execute it unchanged, which is what proves the published build behaves the same on either one. The packaged-install job installs the tarball into a scratch project and uses it the way a consumer would, catching export map and bin problems the in-repo suites cannot see.

bun run test:all runs the first three locally.

Contributing

See CONTRIBUTING.md.

License

MIT

Releases

Packages

Contributors

Languages