Skip to content
Build a serviceDocs 1.3.x

OpenAPI ​

Your service speaks gRPC and Connect, but Swagger UI, API catalogs and SDK generators may need an OpenAPI document. Generate it from the same proto contracts instead of describing the API twice.

Proto options → OpenAPI security

Change the annotation. Inspect the spec.

Keep the RPC unchanged. Switch its auth option and see what the overlay writes into the generated OpenAPI document.

Selected proto method

proto
rpc CheckStock(CheckStockRequest)
    returns (CheckStockResponse) {
  option (connectum.auth.v1.method_auth) = {
    public: true
  };
}

resolveMethodAuth(method)

public
true
internal
false
policy
(unset)

OpenAPI security fragment

POST operation patched

security: [] explicitly removes the operation’s credential requirement.

json
{
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "security": [],
        "x-connectum-public": true
      }
    }
  }
}

Live authz overlay using resolveMethodAuth from @connectum/auth. The base spec is pre-generated; buf/protoc and an RPC server do not run in this page. The controls edit compiled method options, not arbitrary proto text.

Successful build-time overlay. Editing requires JavaScript.

Inspect the complete document after this overlay

The fragment above shows only the fields written by the overlay. The full document retains the generated request, response and error schemas.

json
{
  "openapi": "3.1.0",
  "info": {
    "title": "shop.v1",
    "version": "1.0.0"
  },
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "tags": [
          "shop.v1.InventoryService"
        ],
        "summary": "CheckStock",
        "operationId": "shop.v1.InventoryService.CheckStock",
        "parameters": [
          {
            "name": "Connect-Protocol-Version",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/connect-protocol-version"
            }
          },
          {
            "name": "Connect-Timeout-Ms",
            "in": "header",
            "schema": {
              "$ref": "#/components/schemas/connect-timeout-header"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/shop.v1.CheckStockRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/shop.v1.CheckStockResponse"
                }
              }
            }
          },
          "default": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/connect.error"
                }
              }
            }
          }
        },
        "security": [],
        "x-connectum-public": true
      }
    },
    "/shop.v1.InventoryService/WatchStock": {}
  },
  "components": {
    "schemas": {
      "connect-protocol-version": {
        "type": "number",
        "title": "Connect-Protocol-Version",
        "enum": [
          1
        ],
        "description": "Define the version of the Connect protocol",
        "const": 1
      },
      "connect-timeout-header": {
        "type": "number",
        "title": "Connect-Timeout-Ms",
        "description": "Define the timeout, in ms"
      },
      "connect.error": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "examples": [
              "not_found"
            ],
            "enum": [
              "canceled",
              "unknown",
              "invalid_argument",
              "deadline_exceeded",
              "not_found",
              "already_exists",
              "permission_denied",
              "resource_exhausted",
              "failed_precondition",
              "aborted",
              "out_of_range",
              "unimplemented",
              "internal",
              "unavailable",
              "data_loss",
              "unauthenticated"
            ],
            "description": "The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code]."
          },
          "message": {
            "type": "string",
            "description": "A developer-facing error message, which should be in English. Any user-facing error message should be localized and sent in the [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client."
          },
          "details": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/connect.error_details.Any"
            },
            "description": "A list of messages that carry the error details. There is no limit on the number of messages."
          }
        },
        "title": "Connect Error",
        "additionalProperties": true,
        "description": "Error type returned by Connect: https://connectrpc.com/docs/go/errors/#http-representation"
      },
      "connect.error_details.Any": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "description": "A URL that acts as a globally unique identifier for the type of the serialized message. For example: `type.googleapis.com/google.rpc.ErrorInfo`. This is used to determine the schema of the data in the `value` field and is the discriminator for the `debug` field."
          },
          "value": {
            "type": "string",
            "format": "binary",
            "description": "The Protobuf message, serialized as bytes and base64-encoded. The specific message type is identified by the `type` field."
          },
          "debug": {
            "oneOf": [
              {
                "type": "object",
                "title": "Any",
                "additionalProperties": true,
                "description": "Detailed error information."
              }
            ],
            "discriminator": {
              "propertyName": "type"
            },
            "title": "Debug",
            "description": "Deserialized error detail payload. The 'type' field indicates the schema. This field is for easier debugging and should not be relied upon for application logic."
          }
        },
        "additionalProperties": true,
        "description": "Contains an arbitrary serialized message along with a @type that describes the type of the serialized message, with an additional debug field for ConnectRPC error details."
      },
      "shop.v1.CheckStockRequest": {
        "type": "object",
        "properties": {
          "sku": {
            "type": "string",
            "title": "sku"
          }
        },
        "title": "CheckStockRequest",
        "additionalProperties": false
      },
      "shop.v1.CheckStockResponse": {
        "type": "object",
        "properties": {
          "units": {
            "type": "integer",
            "title": "units",
            "format": "int32"
          }
        },
        "title": "CheckStockResponse",
        "additionalProperties": false
      }
    },
    "securitySchemes": {}
  },
  "security": [],
  "tags": [
    {
      "name": "shop.v1.InventoryService"
    }
  ]
}
Read the overlay and generation input

The page calls this function on a fresh base document and an edited method descriptor. The public/internal conflict guard belongs to this example overlay; reading the options alone does not validate that combination.

typescript
import type { DescMethod } from '@bufbuild/protobuf';
import { resolveMethodAuth } from './generated/auth-reader.mjs';

export type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type JsonObject = { [key: string]: JsonValue };

const object = (value: JsonValue | undefined): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value);

export function jsonDocument(value: unknown): JsonObject {
    const json = (item: unknown): item is JsonValue => {
        if (item === null || typeof item === 'string' || typeof item === 'boolean') return true;
        if (typeof item === 'number') return Number.isFinite(item);
        if (Array.isArray(item)) return item.every(json);
        return typeof item === 'object' && item !== null && Object.values(item).every(json);
    };
    if (!json(value) || !object(value)) throw new Error('The base document must be a JSON object.');
    return value;
}

/** credential metadata does not grant runtime access; always patch a fresh base. */
export function overlayMethod(base: JsonObject, method: DescMethod) {
    const document = structuredClone(base);
    const path = `/${method.parent.typeName}/${method.name}`;
    const paths = document.paths;
    if (!object(paths)) throw new Error('The base OpenAPI document has no paths object.');
    const item = paths[path];
    const operation = object(item) ? item.post : undefined;
    if (!object(operation)) return { kind: 'skipped' as const, document, path };

    const auth = resolveMethodAuth(method);
    document.components ??= {};
    if (!object(document.components)) throw new Error('The base components value is not an object.');
    document.components.securitySchemes ??= {};
    const schemes = document.components.securitySchemes;
    if (!object(schemes)) throw new Error('The base securitySchemes value is not an object.');

    if (auth.public) {
        operation.security = [];
        operation['x-connectum-public'] = true;
    } else if (auth.internal) {
        schemes.internalToken = { type: 'apiKey', in: 'header', name: 'x-internal-token' };
        operation.security = [{ internalToken: [] }];
        operation['x-connectum-internal'] = true;
    } else {
        schemes.bearerAuth = { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
        operation.security = [{ bearerAuth: [] }];
    }
    if (!auth.public && auth.requires?.roles.length) operation['x-connectum-required-roles'] = [...auth.requires.roles];
    if (!auth.public && auth.requires?.scopes.length) operation['x-connectum-required-scopes'] = [...auth.requires.scopes];

    return { kind: 'patched' as const, document, path, auth };
}

/** retain the complete generated document while showing only this overlay's fields. */
export function securityFragment(document: JsonObject, path: string): JsonObject {
    const item = object(document.paths) ? document.paths[path] : undefined;
    const operation = object(item) ? item.post : undefined;
    const fields: JsonObject = {};
    if (object(operation)) {
        for (const [name, value] of Object.entries(operation)) {
            if (name === 'security' || name.startsWith('x-connectum-')) fields[name] = value;
        }
    }
    const components = document.components;
    const schemes = object(components) && object(components.securitySchemes) ? components.securitySchemes : {};
    return {
        paths: { [path]: object(operation) ? { post: fields } : {} },
        ...(Object.keys(schemes).length ? { components: { securitySchemes: schemes } } : {}),
    };
}

The checked-in base was generated with protoc-gen-connect-openapi. A normalization step supplies the required info.version. Regenerating it needs network access; ordinary site builds use the committed artifact. Generator and input versions are recorded in the provenance files.

bash
pnpm openapi:generate
pnpm openapi:base
pnpm openapi:inspect --annotation roles
proto
syntax = "proto3";

package shop.v1;

import "connectum/auth/v1/options.proto";

service InventoryService {
  rpc CheckStock(CheckStockRequest) returns (CheckStockResponse) {
    option (connectum.auth.v1.method_auth) = { public: true };
  }

  rpc WatchStock(CheckStockRequest) returns (stream CheckStockResponse) {
    option (connectum.auth.v1.method_auth) = { public: true };
  }
}

message CheckStockRequest {
  string sku = 1;
}

message CheckStockResponse {
  int32 units = 1;
}

Use the complete generation recipe →

Compare all annotations without JavaScript

CheckStock · Public

json
{
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "security": [],
        "x-connectum-public": true
      }
    }
  }
}

CheckStock · User JWT

json
{
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      }
    }
  }
}

CheckStock · Role + scope

json
{
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-connectum-required-roles": [
          "warehouse"
        ],
        "x-connectum-required-scopes": [
          "stock:read"
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      }
    }
  }
}

CheckStock · Internal service

json
{
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "security": [
          {
            "internalToken": []
          }
        ],
        "x-connectum-internal": true,
        "x-connectum-required-roles": [
          "orders"
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "internalToken": {
        "type": "apiKey",
        "in": "header",
        "name": "x-internal-token"
      }
    }
  }
}

CheckStock · Deny policy

json
{
  "paths": {
    "/shop.v1.InventoryService/CheckStock": {
      "post": {
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT"
      }
    }
  }
}

CheckStock · Conflicting options

@connectum/auth: shop.v1.InventoryService/CheckStock is marked both public and internal, which contradicts itself (public skips every check, internal requires a trusted peer). To make the method public set "public: true, internal: false" on it; to make it internal set "internal: true, public: false" (a method-level "public: false" also overrides a "public: true" inherited from the service).

WatchStock · Public

json
{
  "paths": {
    "/shop.v1.InventoryService/WatchStock": {}
  }
}

WatchStock · User JWT

json
{
  "paths": {
    "/shop.v1.InventoryService/WatchStock": {}
  }
}

WatchStock · Role + scope

json
{
  "paths": {
    "/shop.v1.InventoryService/WatchStock": {}
  }
}

WatchStock · Internal service

json
{
  "paths": {
    "/shop.v1.InventoryService/WatchStock": {}
  }
}

WatchStock · Deny policy

json
{
  "paths": {
    "/shop.v1.InventoryService/WatchStock": {}
  }
}

WatchStock · Conflicting options

json
{
  "paths": {
    "/shop.v1.InventoryService/WatchStock": {}
  }
}
How the generated document and runtime policy relate
Generate OpenAPI and overlay auth metadata The same proto input produces a base OpenAPI document and TypeScript descriptors that meet in an example authorization overlay before the artifact is inspected and committed. Proto contractsMethod schemas + authorization annotations Base spec generationSeparate buf.gen.openapi.yamlRemote Connect OpenAPI pluginNetwork access required TypeScript descriptorsRegenerate from the same proto inputAuth options; offline TS template Base OpenAPI 3.1Connect POST operationsRequest + response schemas Authz overlay (example)resolveMethodAuth(method)public / bearer / internal credentialsx-connectum roles + scopesMatch schemes to runtime trust sources Runtime enforcementInterceptors enforce accessCode rules + gates still apply OpenAPI artifactVerify security + omitted operationsCommit YAML for Swagger, SDKs, catalogs
The overlay is an example script, not a connectum openapi command. It skips absent POST operations, including the documented streaming case. Credential schemes and extensions do not prove that runtime policy permits a call.

One shared policy reader ​

The overlay uses the same annotation reader as proto-based authorization:

typescript
import { resolveMethodAuth } from '@connectum/auth/proto';

const auth = resolveMethodAuth(method);

This excerpt reads one generated method descriptor. The complete generation recipe shows how to write its result into the spec. It is an example script, not a connectum openapi command.

Go deeper ​

Find a section from the full guide

The detailed material is preserved on a separate page. Earlier section links still work here.