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 operationsOpen http://localhost:4000/graphql for GraphiQL, with docs and autocomplete generated from the spec.
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 yamlWithout 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.
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.
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! },
});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.
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.
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| 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.
| 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.
| 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.
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 typeOpenAPI 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.
| 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.
- 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.roundTripGraphQLasks for it only when the input schema already had one.
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.
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 });
},
});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" }
}
}
]
}Converting OpenAPI to GraphQL:
- Local
$refonly. External refs (./other.yaml#/X) degrade to theJSONscalar and produce a warning. Bundle your spec first —redocly bundleorswagger-cli bundle— for full typing. - No input unions. GraphQL has none, so
oneOf/anyOfrequest bodies become theJSONscalar. - JSON and form bodies.
multipart/form-dataand file uploads are not handled. - Swagger 2.0 is partial.
host/basePath/schemesand#/definitions/...refs resolve, butin: bodyparameters 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 orpathForsays otherwise. GraphQL carries no notion of a URL.
warnings on either result — printed by the CLI at startup — lists everything that degraded.
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| 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.
See CONTRIBUTING.md.
MIT