benzene.openapi

An OpenAPI 3.1 document derived from a Benzene handler registry — a sibling projection to the JSON Schema and Cloud Service Profile the port already derives from that same registry. Distribution: benzene-openapi (depends on benzene-core and benzene-http).

pip install benzene-openapi

Overview

Benzene is message-topic-based, not REST, so there is nothing to hand a Swagger UI or a client generator out of the box. openapi_document closes that gap: it projects the same registry the /benzene/spec document and the mesh ServiceDescriptor read into a standard OpenAPI document, reusing benzene.core.json_schema for every payload schema — never re-deriving one.

Like ServiceSpec it is a pure, deterministic registry projection with no third-party runtime dependencies: one POST operation per registered (topic, version) under the profile's /benzene/invoke base, with every request and response schema reused verbatim from json_schema and every failure status mapped to its HTTP code through the port's own benzene.http.to_http table. Mirrors .NET's Benzene.Schema.OpenApi.

Basic usage

from dataclasses import dataclass

from benzene.core import Registry
from benzene.openapi import openapi_document


@dataclass
class PlaceOrder:
    sku: str
    quantity: int


@dataclass
class OrderPlaced:
    order_id: str


registry = Registry().register(
    "orders:place", handler, request_type=PlaceOrder, response_type=OrderPlaced
)

document = openapi_document(registry, title="Orders", version="2.1.0")

document is a plain dict[str, Any] — serialize it with json.dumps, serve it, or feed it to any OpenAPI tool.

openapi_document

openapi_document(
    registry: Registry,
    *,
    title: str = "Benzene service",
    version: str = "1.0.0",
    server_paths: StandardPaths | None = None,
) -> dict[str, Any]

OPENAPI_VERSION is the emitted spec version, "3.1.0" — chosen over 3.0.x because 3.1 adopts the JSON Schema 2020-12 dialect that json_schema already derives, so the embedded schemas drop in unchanged rather than being down-converted.

The mapping

Benzene has no natural resource hierarchy to project, so the faithful anchor is the profile's well-known invoke endpoint (POST /benzene/invoke), through which every transport invokes a topic uniformly. Because an OpenAPI path holds at most one POST operation, each (topic, version) becomes its own sub-path of that base:

So the document reads as one browsable operation per topic, while the operations all live under the single invoke base the wire binding actually multiplexes them through. The invoke base itself comes from StandardPaths (benzene.http).

Schemas are reused, never re-derived

Every operation's requestBody and success response $ref a component in components.schemas whose value is exactly benzene.core.json_schema of the topic's request/response type — the same 2020-12 subset the spec and descriptor embed, with wire-naming (orderId) property names and required tracking the caller's obligation. Failure responses map the Benzene failure statuses to HTTP codes through benzene.http.to_http and share one BenzeneError problem-details component ({status, detail}, the wire-contract failure envelope), ordered by HTTP code.

Deterministic output

As with ServiceSpec, the document is built in a stable order — topics sorted by (id, version), paths and component-schema keys sorted lexicographically, responses ordered by HTTP code — so the same registry always yields an identical, diff-friendly dict.

Emitted document

{
  "openapi": "3.1.0",
  "info": { "title": "Orders", "version": "2.1.0" },
  "paths": {
    "/benzene/invoke/orders:place": {
      "post": {
        "operationId": "ordersPlace",
        "summary": "Invoke topic orders:place",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/OrdersPlaceRequest" }
            }
          }
        },
        "responses": {
          "200": { "description": "ok", "content": { "application/json": {
            "schema": { "$ref": "#/components/schemas/OrdersPlaceResponse" } } } },
          "400": { "description": "bad-request", "content": { "application/json": {
            "schema": { "$ref": "#/components/schemas/BenzeneError" } } } }
          // ... one entry per failure status, ordered by HTTP code
        }
      }
    }
  },
  "components": {
    "schemas": {
      "BenzeneError": { "type": "object",
        "properties": { "status": { "type": "string" }, "detail": { "type": "string" } },
        "required": ["status", "detail"] },
      "OrdersPlaceRequest": { "type": "object", "properties": {
        "sku": { "type": "string" }, "quantity": { "type": "integer" } },
        "required": ["sku", "quantity"] },
      "OrdersPlaceResponse": { "type": "object", "properties": {
        "orderId": { "type": "string" } }, "required": ["orderId"] }
    }
  }
}

operation_id

The canonical operationId for a (topic, version) — the camelCased topic, with the version suffixed:

from benzene.openapi import operation_id

operation_id("orders:place")          # "ordersPlace"
operation_id("orders:place", "v2")    # "ordersPlace_v2"
operation_id(topic: str, version: str = "") -> str

It is deterministic per pair. openapi_document uses it internally, and exposes it so a caller can correlate a topic to the operation it will emit.

Separator-collision disambiguation

Two topics that differ only by separator — orders:place versus orders-place — camel/PascalCase to the same base name, which would silently overwrite a component schema and emit a duplicate operationId (OpenAPI requires uniqueness). The raw-topic path still distinguishes them, so openapi_document disambiguates the derived names against those already emitted: because definitions are walked in sorted (id, version) order, the first claimant keeps the clean name (ordersPlace, OrdersPlaceRequest) and later ones gain a _2 / _3 suffix. The output therefore stays valid and byte-stable regardless of separator collisions.

Exports

openapi_document, operation_id, OPENAPI_VERSION.

See also