Generate an OpenAPI v3 spec from a persisted-only GraphQL API, so external systems can use it without a second REST API.
You already have a GraphQL API locked to persisted operations and used internally. Building and maintaining a separate REST API for external consumers costs time and money. Each persisted operation has a fixed name, variables and response shape, so it maps directly to an HTTP endpoint. peroapi turns your schema and persisted operations into a typed OpenAPI spec that works with codegen, gateways, Postman and docs portals.
peroapi only generates the spec. It runs no server, so an API gateway or thin handler must route requests to your persisted operations.
Never publish internal operations. They change with the UI, and external systems can't follow. Instead:
- Separate operations: write dedicated public queries and mutations in their own persisted operations file (e.g.
gql-persisted.public.json). - Stable contract: treat a public operation like a REST endpoint. Only make additive changes (new optional variables, new response fields). For breaking changes, add a new operation (e.g.
GetUserV2) and deprecate the old one. - Public set only: generate the spec from the public file, so internal operations never appear in it.
peroapi generate \
--schema schema.graphql \
--operations gql-persisted.public.json \
--output openapi.public.yamlEach operation becomes a route ({base-path}/{OperationName}), with variables and response selection sets resolved against the schema into typed OpenAPI schemas.
go install github.com/Thomasparsley/peroapi@latestOr build from source:
git clone https://github.com/Thomasparsley/peroapi.git
cd peroapi
go build -o peroapi .peroapi generate --schema <schema.graphql> --operations <gql-persisted.json> [flags]| Flag | Short | Default | Description |
|---|---|---|---|
--schema |
-s |
— | Path to the GraphQL schema (required) |
--operations |
-o |
— | Path to the persisted operations map JSON (required) |
--output |
(prompt) | Path to write the OpenAPI YAML. If omitted, you're asked whether to print to stdout | |
--method-convention |
rest |
HTTP method strategy: rest, post, or get |
|
--base-path |
/api/graphql |
Path prefix for every generated route | |
--title |
Generated API |
Value for OpenAPI info.title |
|
--version |
1.0.0 |
Value for OpenAPI info.version |
rest(default) — queries map toGET, mutations map toPOST.post— every operation maps toPOST.get— every operation maps toGET.
For GET routes, operation variables become individual query parameters. For POST routes, variables are packed into a JSON request body under a variables object.
Persisted operations map (gql-persisted.json) — a flat object mapping operation names to their GraphQL document strings:
{
"GetUser": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
"CreateUser": "mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name } }"
}Schema (schema.graphql) — a standard GraphQL SDL file defining the types referenced by your operations:
input CreateUserInput {
name: String!
email: String!
}
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
type Mutation {
createUser(input: CreateUserInput!): User!
}Built-in scalars map to their OpenAPI equivalents. Custom scalars the schema declares are recognized by name:
| Scalar | OpenAPI schema |
|---|---|
Byte |
integer/int32, -128 to 127 |
UnsignedByte |
integer/int32, 0 to 255 |
Short |
integer/int32, -32768 to 32767 |
UnsignedShort |
integer/int32, 0 to 65535 |
UnsignedInt |
integer/int64, 0 to 4294967295 |
Long |
integer/int64 |
UnsignedLong |
integer, minimum 0 (the 2^64-1 maximum doesn't fit int64, so it is not declared) |
LocalDate |
string/date |
Any other custom scalar becomes a plain string with an x-graphql-scalar: <Name> extension.
Names are matched literally and follow Hot Chocolate's conventions, where Byte is signed and UnsignedByte is unsigned. If your server gives a name different semantics, the generated range for that scalar is wrong.
Generate a spec using the default REST convention and write it to a file:
peroapi generate \
--schema schema.graphql \
--operations gql-persisted.json \
--output openapi.yamlGenerate with every operation forced to POST. With no --output, peroapi prompts whether to print to stdout:
peroapi generate -s schema.graphql -o gql-persisted.json --method-convention post
# No output file specified. Print to stdout? [Y/n]:Customize the route prefix and API metadata:
peroapi generate \
-s schema.graphql \
-o gql-persisted.json \
--base-path /v1/rpc \
--title "Orders API" \
--version 2.3.0 \
--output orders-openapi.yamlWith the rest convention, a query like GetUser becomes a GET route (variables as query parameters) and a mutation like CreateUser becomes a POST route (variables in the request body):
openapi: 3.0.3
info:
title: Generated API
version: 1.0.0
paths:
/api/graphql/GetUser:
get:
operationId: GetUser
parameters:
- name: id
in: query
required: true
schema:
type: string
responses:
"200":
description: Successful response
# ... typed data + errors object
/api/graphql/CreateUser:
post:
operationId: CreateUser
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
variables:
type: object
properties:
input:
$ref: '#/components/schemas/CreateUserInput'
responses:
"200":
description: Successful response
# ... typed data + errors objectSee LICENSE.