Generating Go code from OpenAPI schemas

Turn OpenAPI schemas into clean, dependency-free Go models.

The golang generator turns OpenAPI schemas into Go model types. You hand it libopenapi schema models and it returns gofmt’d Go source, along with a set of diagnostics that describe any schema shape that didn’t map cleanly onto a plain Go field.

It’s library-only. There is no CLI, no generated client or server, and no runtime package to import. It generates source code, and nothing else. The generated models depend only on the Go standard library.

The generator is part of the core libopenapi module, so there’s no separate dependency to add. Import github.com/pb33f/libopenapi/generator/golang and you’re ready to go.

Available since libopenapi vX.Y.Z.

Rendering a single schema

RenderSchema takes a name and a *base.SchemaProxy, and returns formatted Go source. Here we parse a small specification, pull the Product schema out of components.schemas, and render it:

package main

import (
    "fmt"

    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/generator/golang"
)

const spec = `openapi: 3.1.0
info:
  title: Catalog
  version: 1.0.0
paths: {}
components:
  schemas:
    Product:
      type: object
      description: A product in the catalog.
      required: [id, name, price]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        price:
          type: number
          format: double
        status:
          type: string
          enum: [draft, active, discontinued]
        dimensions:
          type: object
          properties:
            width:
              type: number
            height:
              type: number
`

func main() {

    // parse an OpenAPI document from bytes
    doc, err := libopenapi.NewDocument([]byte(spec))
    if err != nil {
        panic(err)
    }

    // build the v3 model so the component schemas are available
    model, errs := doc.BuildV3Model()
    if errs != nil {
        panic(errs)
    }

    // pull the Product schema out of components.schemas
    product, _ := model.Model.Components.Schemas.Get("Product")

    // render it as Go source
    source, err := golang.RenderSchema("Product", product)
    if err != nil {
        panic(err)
    }

    fmt.Print(string(source))
}

This prints:

package models type Product_Status string type Product_Dimensions struct { Width *float64 `json:"width,omitempty"` Height *float64 `json:"height,omitempty"` } // Product A product in the catalog. type Product struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` Status *Product_Status `json:"status,omitempty"` Dimensions *Product_Dimensions `json:"dimensions,omitempty"` }

Inline objects (dimensions) and enums (status) are lifted into their own named types, using _ as the delimiter between parent and child. Required fields are values; optional fields are pointers with omitempty. The schema description becomes a doc comment.

Rendering a whole component map

RenderSchemas takes the entire components.schemas map and returns a single *GeneratedFile. A $ref between two components becomes a typed field that points at the generated type:

package main

import (
    "fmt"

    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/generator/golang"
)

const spec = `openapi: 3.1.0
info:
  title: Catalog
  version: 1.0.0
paths: {}
components:
  schemas:
    Product:
      type: object
      description: A product in the catalog.
      required: [id, name, price]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        price:
          type: number
          format: double
        category:
          $ref: '#/components/schemas/Category'
    Category:
      type: object
      required: [name]
      properties:
        name:
          type: string
        parent:
          type: string
`

func main() {
    doc, err := libopenapi.NewDocument([]byte(spec))
    if err != nil {
        panic(err)
    }
    model, errs := doc.BuildV3Model()
    if errs != nil {
        panic(errs)
    }

    // render every schema in components.schemas into one file
    gen := golang.NewGenerator(golang.WithPackageName("catalog"))
    file, err := gen.RenderSchemas(model.Model.Components.Schemas)
    if err != nil {
        panic(err)
    }

    fmt.Printf("package name: %s\n", file.PackageName)
    fmt.Print("types: ")
    for i, t := range file.Types {
        if i > 0 {
            fmt.Print(", ")
        }
        fmt.Print(t.Name)
    }
    fmt.Printf("\ndiagnostics: %d\n", len(file.Diagnostics))
    fmt.Print("---\n")
    fmt.Print(string(file.Source))
}

This prints:

package name: catalog types: Product, Category diagnostics: 0 --- package catalog // Product A product in the catalog. type Product struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` Category *Category `json:"category,omitempty"` } type Category struct { Name string `json:"name"` Parent *string `json:"parent,omitempty"` }

What you get back

RenderSchemas returns a *GeneratedFile. RenderSchema is a shortcut that returns just the Source bytes.

Field Description
PackageName The package name written at the top of the file.
Source The gofmt’d Go source, as bytes.
Types One entry per top-level generated type, each with a Name and a Kind.
Diagnostics Notable or lossy decisions made while generating (see below).
SchemaMetadata Optional sidecar source for high-fidelity round trips. nil unless enabled.

How OpenAPI shapes map to Go

OpenAPI schema Generated Go
object with properties a struct
array a slice, []T
object with a schema additionalProperties map[string]T plus marshal/unmarshal methods
string / integer / number / boolean string / int / float64 / bool
integer with format: int32 / int64 int32 / int64
number with format: float / double float32 / float64
enum of a single scalar type a named type, plus typed constants when enabled
oneOf with a discriminator (or shared const) a typed union: an interface and a …Union wrapper
ambiguous oneOf / anyOf a json.RawMessage wrapper
a property not in required a pointer field with omitempty
null in the type, or a nullable reference a pointer

A string format stays a string unless you map it yourself with WithFormatMapping (covered below). Validation keywords such as minimum or pattern describe constraints, not shape, so they don’t change the generated Go. The generator records a diagnostic when it sees them.

Diagnostics

The generator never silently drops information. Anything it can’t represent as a plain Go field, or any decision worth knowing about, is reported as a Diagnostic with a Code, a Path, and a Message:

package main

import (
    "fmt"

    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/generator/golang"
)

const spec = `openapi: 3.1.0
info:
  title: Catalog
  version: 1.0.0
paths: {}
components:
  schemas:
    Product:
      type: object
      required: [price]
      properties:
        price:
          type: number
          minimum: 0
        tags:
          type: array
          items:
            type: string
          uniqueItems: true
`

func main() {
    doc, err := libopenapi.NewDocument([]byte(spec))
    if err != nil {
        panic(err)
    }
    model, errs := doc.BuildV3Model()
    if errs != nil {
        panic(errs)
    }

    file, err := golang.NewGenerator().RenderSchemas(model.Model.Components.Schemas)
    if err != nil {
        panic(err)
    }

    for _, d := range file.Diagnostics {
        fmt.Printf("[%s] %s: %s\n", d.Code, d.Path, d.Message)
    }
}

This prints:

[validationKeyword] Product.price: JSON Schema validation keywords are not enforced by generated Go models [validationKeyword] Product.tags: JSON Schema validation keywords are not enforced by generated Go models

Shaping the output

The default settings produce idiomatic models, but every decision is configurable through options passed to RenderSchema, RenderSchemas, or NewGenerator.

Option Effect
WithPackageName(name) Set the package name. Defaults to models.
WithGeneratedComment(true) Add the // Code generated … DO NOT EDIT. header.
WithOptionalFieldsAsPointers(false) Render optional fields as values instead of pointers.
WithOmitEmpty(false) Drop omitempty from optional tags.
WithGenerateYAMLTags(true) Add yaml tags alongside the json tags.
WithEnumConstants(true) Emit a typed constant for each enum value.
WithOptionalConstDiscriminatorUnions(true) Let an optional shared const property still form a typed union.
WithAdditionalPropertiesMethods(false) Skip the generated marshal/unmarshal methods for additionalProperties.
WithNestedTypeNameDelimiter("") Change the parent/child delimiter. Defaults to _.
WithFormatMapping(format, goType, import) Map an OpenAPI string format to a Go type and import path.
WithTypeNameResolver(fn) Supply your own naming for generated types (also fields, enums, and refs).
WithSchemaMetadataSidecar(true) Emit a metadata sidecar for high-fidelity round trips.

Typed unions

A oneOf whose variants share a required property with a distinct const value becomes a typed union. The generator emits an interface, one concrete type per variant, and a …Union wrapper whose UnmarshalJSON switches on the discriminator:

package main

import (
    "fmt"

    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/generator/golang"
)

const spec = `openapi: 3.1.0
info:
  title: Catalog
  version: 1.0.0
paths: {}
components:
  schemas:
    Payment:
      oneOf:
        - title: Card
          type: object
          required: [kind, number]
          properties:
            kind:
              type: string
              const: card
            number:
              type: string
        - title: Bank
          type: object
          required: [kind, account]
          properties:
            kind:
              type: string
              const: bank
            account:
              type: string
`

func main() {
    doc, err := libopenapi.NewDocument([]byte(spec))
    if err != nil {
        panic(err)
    }
    model, errs := doc.BuildV3Model()
    if errs != nil {
        panic(errs)
    }
    payment, _ := model.Model.Components.Schemas.Get("Payment")

    source, err := golang.RenderSchema("Payment", payment)
    if err != nil {
        panic(err)
    }
    fmt.Print(string(source))
}

This prints:

package models import ( "encoding/json" "fmt" ) // Payment_Card Card. type Payment_Card struct { Kind string `json:"kind"` Number string `json:"number"` } // Payment_Bank Bank. type Payment_Bank struct { Kind string `json:"kind"` Account string `json:"account"` } type Payment interface { isPayment() } func (Payment_Card) isPayment() {} func (Payment_Bank) isPayment() {} type PaymentUnion struct { Value Payment } func (u PaymentUnion) MarshalJSON() ([]byte, error) { if u.Value == nil { return []byte("null"), nil } return json.Marshal(u.Value) } func (u PaymentUnion) IsZero() bool { return u.Value == nil } func (u *PaymentUnion) UnmarshalJSON(data []byte) error { var discriminator struct { Value string `json:"kind"` } if err := json.Unmarshal(data, &discriminator); err != nil { return err } switch discriminator.Value { case "bank": var v Payment_Bank if err := json.Unmarshal(data, &v); err != nil { return err } u.Value = v case "card": var v Payment_Card if err := json.Unmarshal(data, &v); err != nil { return err } u.Value = v default: return fmt.Errorf("unknown kind discriminator value %q", discriminator.Value) } return nil }

An explicit discriminator in the schema works the same way. When a oneOf or anyOf is ambiguous (no discriminator and no shared const), the generator renders a json.RawMessage wrapper instead, so the model stays valid and dependency-free without guessing at the shape.

Enums as constants

WithEnumConstants(true) emits a typed constant for each enum value:

package main

import (
    "fmt"

    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/generator/golang"
)

const spec = `openapi: 3.1.0
info:
  title: Catalog
  version: 1.0.0
paths: {}
components:
  schemas:
    Status:
      type: string
      enum: [draft, active, discontinued]
`

func main() {
    doc, err := libopenapi.NewDocument([]byte(spec))
    if err != nil {
        panic(err)
    }
    model, errs := doc.BuildV3Model()
    if errs != nil {
        panic(errs)
    }
    status, _ := model.Model.Components.Schemas.Get("Status")

    source, err := golang.RenderSchema("Status", status, golang.WithEnumConstants(true))
    if err != nil {
        panic(err)
    }
    fmt.Print(string(source))
}

This prints:

package models type Status string const ( StatusDraft Status = "draft" StatusActive Status = "active" StatusDiscontinued Status = "discontinued" )

Round-tripping unknown fields

When a schema has a schema-valued additionalProperties, the generated model keeps the known fields as struct fields and collects everything else into an AdditionalProperties map. The generator also emits MarshalJSON and UnmarshalJSON methods so unknown fields survive a decode/encode cycle:

package main

import (
    "fmt"

    "github.com/pb33f/libopenapi"
    "github.com/pb33f/libopenapi/generator/golang"
)

const spec = `openapi: 3.1.0
info:
  title: Catalog
  version: 1.0.0
paths: {}
components:
  schemas:
    Labels:
      type: object
      properties:
        name:
          type: string
      additionalProperties:
        type: string
`

func main() {
    doc, err := libopenapi.NewDocument([]byte(spec))
    if err != nil {
        panic(err)
    }
    model, errs := doc.BuildV3Model()
    if errs != nil {
        panic(errs)
    }
    labels, _ := model.Model.Components.Schemas.Get("Labels")

    source, err := golang.RenderSchema("Labels", labels)
    if err != nil {
        panic(err)
    }
    fmt.Print(string(source))
}

This prints:

package models import "encoding/json" type Labels struct { Name *string `json:"name,omitempty"` AdditionalProperties map[string]string `json:"-"` } func (m *Labels) UnmarshalJSON(data []byte) error { type Alias Labels var known Alias if err := json.Unmarshal(data, &known); err != nil { return err } *m = Labels(known) var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } delete(raw, "name") if len(raw) == 0 { return nil } m.AdditionalProperties = make(map[string]string, len(raw)) for key, value := range raw { var decoded string if err := json.Unmarshal(value, &decoded); err != nil { return err } m.AdditionalProperties[key] = decoded } return nil } func (m Labels) MarshalJSON() ([]byte, error) { type Alias Labels encoded, err := json.Marshal(Alias(m)) if err != nil { return nil, err } var object map[string]json.RawMessage if err := json.Unmarshal(encoded, &object); err != nil { return nil, err } for key, value := range m.AdditionalProperties { encodedValue, err := json.Marshal(value) if err != nil { return nil, err } object[key] = encodedValue } return json.Marshal(object) }

Pass WithAdditionalPropertiesMethods(false) if you’d rather provide the JSON behavior yourself.

Going the other way

The generator also runs in reverse: hand it Go types and it produces OpenAPI schemas. See Parsing Code for the return trip, and for the metadata sidecar that makes an OpenAPI → Go → OpenAPI round trip lossless.