Fabrica Blog

Simple guides introducing Fabrica with hands-on examples.

View the Project on GitHub OpenCHAMI/fabrica

API Versioning Guide

Hub/spoke API versioning with automatic conversion, storage stability, and smooth client migrations.

Table of Contents

Overview

Fabrica implements Kubebuilder-style hub/spoke versioning to provide stable APIs while allowing evolution of your resource schemas over time. This allows you to:

Hub vs. Spoke

┌─────────────────────────────────────────┐
│           Client Request                │
│   (apiVersion: infra.example.io/v1beta1)│
└──────────────────┬──────────────────────┘
                   │
                   ▼
         ┌─────────────────┐
         │ Version Middleware│
         │  (negotiation)   │
         └────────┬──────────┘
                  │
                  ▼ Convert to Hub
         ┌────────────────┐
         │   Hub (v1)     │  ◄── Storage always uses this
         │ Storage Version │
         └────────┬────────┘
                  │
                  ▼ Convert to Requested Spoke
         ┌─────────────────┐
         │  Spoke (v1beta1) │
         │  Response        │
         └──────────────────┘

Why Hub/Spoke Versioning

Storage Stability

Your internal storage format (the “hub”) remains stable while external APIs (the “spokes”) evolve independently. This allows you to:

Client Stability

Clients can pin to a specific API version and continue working even as you add new features to newer versions.

Safe Evolution

Breaking changes to your types can be introduced in a new spoke version while the hub remains unchanged.

Versioning Model

The apis.yaml file defines your API groups and versions:

groups:
  - name: infra.example.io         # API group name
    storageVersion: v1              # Hub version (used for storage)
    versions:                       # Spoke versions (external APIs)
      - v1alpha1                    # Alpha version (unstable)
      - v1beta1                     # Beta version (semi-stable)
      - v1                          # Stable version
    imports:                        # Optional: import external types
      - module: github.com/org/pkg
        tag: v1.0.0
        packages:
          - path: api/types
            expose:
              - kind: MyResource
                specFrom: pkg.MyResourceSpec
                statusFrom: pkg.MyResourceStatus

Version Stability Levels

Quick Start

By default, Fabrica generates resources with a single version (v1) that acts as both hub and spoke. To enable multi-version support:

Adding a New API Version

Step 1: Add the version to your project

# Add an alpha version (for experimentation)
fabrica add version v2alpha1

# Or add a stable version (requires --force)
fabrica add version v2 --force

This command:

Step 2: Add or modify resources in the new version

# Add a new resource to the alpha version
fabrica add resource Feature --version v2alpha1

# Modify existing resources in the version directory
# Edit apis/<group>/v2alpha1/device_types.go

Important: You must use fabrica add version before adding resources to a new version. If you try to add a resource to a non-existent version:

fabrica add resource Device --version v2
# Error: version v2 not found in apis.yaml (available: [v1])
#
# To add a new version, run: fabrica add version v2

Step 3: Generate handlers and conversions

fabrica generate

This generates:

Version Registration

Versioning is driven by apis.yaml plus CLI scaffolding. No manual registration is required.

Single version (default): 1) fabrica init myapi --group example.fabrica.dev --versions v1 2) fabrica add resource Device --version v1 3) Edit apis/example.fabrica.dev/v1/device_types.go 4) fabrica generate

Add another version (spoke): 1) fabrica add version v2beta1 (copies types into apis/example.fabrica.dev/v2beta1/ and updates apis.yaml) 2) Update the v2beta1 types as needed 3) Add converters under apis/example.fabrica.dev/v2beta1/converter.go 4) fabrica generate

Generated Code Structure

With versioning enabled, Fabrica generates:

apis/
└── infra.example.io/
    ├── v1/                          # Hub (storage version)
    │   ├── types_generated.go       # Flattened Device type
    │   └── register_generated.go
    ├── v1beta1/                     # Spoke (external version)
    │   ├── types_generated.go       # Flattened Device type
    │   └── conversions_generated.go # Conversion to/from hub
    └── v1alpha1/                    # Spoke (external version)
        ├── types_generated.go
        └── conversions_generated.go

Hub Type Example (apis/infra.example.io/v1/types_generated.go)

package v1

type Device struct {
    APIVersion string       `json:"apiVersion"` // "infra.example.io/v1"
    Kind       string       `json:"kind"`       // "Device"
    Metadata   Metadata     `json:"metadata"`
    Spec       DeviceSpec   `json:"spec"`
    Status     DeviceStatus `json:"status,omitempty"`
}

// IsHub marks this as the hub version
func (Device) IsHub() {}

Spoke Type Example (apis/infra.example.io/v1beta1/types_generated.go)

package v1beta1

import v1 "yourmodule/apis/infra.example.io/v1"

type Device struct {
    APIVersion string       `json:"apiVersion"` // "infra.example.io/v1beta1"
    Kind       string       `json:"kind"`
    Metadata   Metadata     `json:"metadata"`
    Spec       DeviceSpec   `json:"spec"`
    Status     DeviceStatus `json:"status,omitempty"`
}

// ConvertTo converts this spoke to the hub
func (src *Device) ConvertTo(dstRaw interface{}) error {
    dst := dstRaw.(*v1.Device)
    // Field-by-field conversion logic...
    return nil
}

// ConvertFrom converts from the hub to this spoke
func (dst *Device) ConvertFrom(srcRaw interface{}) error {
    src := srcRaw.(*v1.Device)
    // Field-by-field conversion logic...
    return nil
}

Conversion Patterns

Define Version Structs

v1 (stable/hub):

// apis/example.fabrica.dev/v1/device_types.go
package v1

type Device struct {
    APIVersion string       `json:"apiVersion"`
    Kind       string       `json:"kind"`
    Metadata   Metadata     `json:"metadata"`
    Spec       DeviceSpec   `json:"spec"`
    Status     DeviceStatus `json:"status,omitempty"`
}

type DeviceSpec struct {
    Name     string `json:"name"`
    Location string `json:"location"`
    Username string `json:"username"` // Flat auth
    Password string `json:"password"`
}

v2beta1 (spoke with structured auth):

// apis/example.fabrica.dev/v2beta1/device_types.go
package v2beta1

type Device struct {
    APIVersion string       `json:"apiVersion"`
    Kind       string       `json:"kind"`
    Metadata   Metadata     `json:"metadata"`
    Spec       DeviceSpec   `json:"spec"`
    Status     DeviceStatus `json:"status,omitempty"`
}

type DeviceSpec struct {
    Name     string `json:"name"`
    Location string `json:"location"`
    Auth     AuthConfig `json:"auth"` // Structured auth
}

type AuthConfig struct {
    Type     string `json:"type"` // "basic", "oauth", "cert"
    Username string `json:"username,omitempty"`
    Password string `json:"password,omitempty"`
    Token    string `json:"token,omitempty"`
}

Implement Converter

// apis/example.fabrica.dev/v2beta1/converter.go
package v2beta1

import (
    v1 "yourmodule/apis/example.fabrica.dev/v1"
)

// ConvertTo converts v2beta1 Device to v1 (hub)
func (src *Device) ConvertTo(dstRaw interface{}) error {
    dst := dstRaw.(*v1.Device)

    // Copy flattened envelope fields
    dst.APIVersion = src.APIVersion
    dst.Kind = src.Kind
    dst.Metadata = src.Metadata

    // Standard field copy
    dst.Spec.Name = src.Spec.Name
    dst.Spec.Location = src.Spec.Location

    // Custom transformation: v2beta1 structured auth → v1 flat auth
    if src.Spec.Auth.Type == "basic" {
        dst.Spec.Username = src.Spec.Auth.Username
        dst.Spec.Password = src.Spec.Auth.Password
    } else {
        log.Warn("Non-basic auth will be lost in v1 conversion")
    }

    dst.Status = src.Status
    return nil
}

// ConvertFrom converts v1 (hub) Device to v2beta1
func (dst *Device) ConvertFrom(srcRaw interface{}) error {
    src := srcRaw.(*v1.Device)

    // Copy flattened envelope fields
    dst.APIVersion = src.APIVersion
    dst.Kind = src.Kind
    dst.Metadata = src.Metadata

    // Standard field copy
    dst.Spec.Name = src.Spec.Name
    dst.Spec.Location = src.Spec.Location

    // Custom transformation: v1 flat auth → v2beta1 structured auth
    dst.Spec.Auth = AuthConfig{
        Type:     "basic",
        Username: src.Spec.Username,
        Password: src.Spec.Password,
    }

    dst.Status = src.Status
    return nil
}

Register Converter

No manual registration is needed; the generator wires converters automatically:

1) Add your converter next to the versioned types (e.g., apis/example.fabrica.dev/v2beta1/converter.go). 2) Export a constructor: func NewDeviceConverter() versioning.VersionConverter { return &DeviceConverter{} }. 3) Run fabrica generate; the generator discovers and registers converters via apis.yaml.

HTTP Negotiation

Clients can request a specific version using the apiVersion field in the request body, an explicit versioned URL, or the Accept header.

Precedence (highest to lowest):

  1. apiVersion in request body (POST/PUT/PATCH)
  2. Explicit version in URL (e.g., /apis/<group>/<version>/...)
  3. Accept header (application/json;version=v1beta1)
  4. Default/storage version
curl -X POST http://localhost:8080/devices \
  -H "Content-Type: application/json" \
  -d '{
    "apiVersion": "infra.example.io/v1beta1",
    "kind": "Device",
    "metadata": {"name": "device-01"},
    "spec": { ... }
  }'

Via Accept Header (Alternative)

curl -X GET http://localhost:8080/devices/device-01 \
  -H "Accept: application/json; api-version=infra.example.io/v1beta1"

If no version is specified, the server returns the preferred version (typically the storage version). If a requested version is not registered in apis.yaml, the server responds with 406 Not Acceptable.

Conversion Flow

Request Flow:

  1. Client sends request with apiVersion: infra.example.io/v1beta1
  2. Middleware decodes into v1beta1.Device type
  3. Middleware converts to v1.Device (hub) via ConvertTo()
  4. Handler/storage operates on hub version

Response Flow:

  1. Handler returns v1.Device (hub)
  2. Middleware converts to v1beta1.Device via ConvertFrom()
  3. Response sent to client as v1beta1

Using curl

# Request v1
curl -H "Accept: application/json;version=v1" \
  http://localhost:8080/devices/dev-123

# Request v2beta1
curl -H "Accept: application/json;version=v2beta1" \
  http://localhost:8080/devices/dev-123

# Request default version (omit version)
curl http://localhost:8080/devices/dev-123

Custom Version Negotiation

For complex negotiation strategies, you can implement custom middleware in your project:

// Custom version negotiation middleware
func VersionNegotiation(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Check client header, query param, or other criteria
        version := r.Header.Get("X-API-Version")
        if version == "" {
            version = r.URL.Query().Get("version")
        }
        if version == "" {
            version = "v1" // default
        }

        // Store in context
        ctx := context.WithValue(r.Context(), "version", version)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Migration Strategies

Day 1: Launch v2, deprecate v1
Day 30: Remove v1

Pros: Simple Cons: Breaks clients, forces immediate migration

Day 1: Launch v2beta1 for testing
Day 30: Promote to v2 stable
Day 60: Mark v1 as deprecated
Day 180: Remove v1 support

Pros: Smooth transition, no breakage Cons: More work, temporary maintenance burden

Strategy 3: Parallel Versions

Support v1, v2, v3 indefinitely

Pros: Maximum compatibility Cons: Significant maintenance burden

Implementation Timeline Example

Phase 1: Launch Beta (Day 1)

fabrica add version v2beta1
# Edit apis/example.fabrica.dev/v2beta1/*_types.go and converter
fabrica generate

Phase 2: Promote to Stable (Day 30)

# Update converters if needed
fabrica generate

Phase 3: Deprecate Old Version (Day 60)

# Communication to clients: "v1 deprecated; migrate to v2 by Day 180"

Phase 4: Remove Old Version (Day 180)

# Remove v1 from apis.yaml
# Update all links and documentation
fabrica generate

Breaking Changes and Migration

When making breaking changes to your types, you have two options:

  1. Keep the hub (v1) unchanged
  2. Add a new spoke (v2beta1) with the breaking change
  3. Implement custom conversion logic to handle field changes
  4. Deprecate the old spoke version gradually

Example: Renaming a field

# apis.yaml
groups:
  - name: infra.example.io
    storageVersion: v1              # Hub stays unchanged
    versions:
      - v1alpha1                     # Old version
      - v1beta1                      # Current version
      - v2beta1                      # New version with breaking change
      - v1                           # Stable

Field renaming conversion:

// apis/example.fabrica.dev/v2beta1/converter.go
func (src *Device) ConvertTo(dstRaw interface{}) error {
    dst := dstRaw.(*v1.Device)

    // Custom transformation: v2beta1 "hostname" → v1 "ipAddress"
    dst.Spec.IPAddress = src.Spec.Hostname

    return nil
}

Option 2: Bump the Hub (Major Version Bump)

When the hub itself needs to change (rare), and you want to remove deprecated fields:

  1. Create a new hub version (v2)
  2. Migrate storage data from v1 to v2
  3. Update old spoke versions to convert to/from v2
  4. Deprecate the old hub

Note: This is complex and should be avoided in most cases. Prefer keeping the hub stable and using spokes.

Best Practices

Version Design

DO:

 Use semantic versioning (v1, v2, v3)
 Mark stability (alpha, beta, stable)
 Provide bidirectional conversion
 Document breaking changes in release notes
 Give deprecation warnings to clients
 Test all conversion paths thoroughly

DON’T:

 Use arbitrary version strings
 Break existing versions without warning
 Skip alpha/beta for major changes
 Remove versions without deprecation period
 Forget to update converters when changing fields

Conversion Best Practices

DO:

 Handle all field mappings
 Document lossy conversions (data loss warnings)
 Provide sensible default values for missing fields
 Test all conversion paths (v1v2 and v2v1)
 Log conversion warnings for complex transformations

Example:

func (src *Device) ConvertTo(dstRaw interface{}) error {
    dst := dstRaw.(*v1.Device)

    // Safe field copy
    dst.Spec.Name = src.Spec.Name

    // Lossy conversion with warning
    if src.Spec.Auth.Type != "basic" {
        log.Warn("Non-basic auth will be lost in v1 conversion")
    }

    // Provide default value
    if src.Spec.Timeout == 0 {
        dst.Spec.Timeout = 30 * time.Second
    } else {
        dst.Spec.Timeout = src.Spec.Timeout
    }

    return nil
}

Migration Communication

DO:

✅ Announce deprecation in release notes
✅ Provide migration guides with examples
✅ Support multiple versions during transition (3-6 months minimum)
✅ Include links to migration documentation in error messages
✅ Monitor version usage metrics

Example deprecation notice:

## Deprecation Notice (v1.5.0)

The Device API v1 is deprecated and will be removed in v2.0 (Q4 2025).

**Action required:** Migrate to Device API v2 by October 31, 2025.

See [Migration Guide](https://docs.example.io/migration) for step-by-step instructions.

Troubleshooting

Error: “apiVersion not supported”

Cause: Client requested a version not in the apis.yaml versions list, or the version registry was not generated/imported in the server.

Solution: Add the version to apis.yaml or update the client to use a supported version. If the registry is missing, run fabrica generate and ensure _ "<module>/pkg/apiversion" is imported in cmd/server/main.go.

# apis.yaml
groups:
  - name: infra.example.io
    versions:
      - v1         # Add missing version here
      - v2beta1

Error: “Conversion failed”

Cause: Field mismatch between hub and spoke (e.g., renamed field without conversion logic).

Solution: Implement custom conversion logic in the generated ConvertTo/ConvertFrom functions.

func (src *Device) ConvertTo(dstRaw interface{}) error {
    dst := dstRaw.(*v1.Device)
    // Ensure all fields are mapped
    dst.Spec.NewFieldName = src.Spec.OldFieldName
    return nil
}

Error: “ConvertFrom not implemented”

Cause: Spoke type is missing the conversion handler.

Solution: Ensure the converter is in the correct package and is exported.

// apis/example.fabrica.dev/v2beta1/converter.go
func (dst *Device) ConvertFrom(srcRaw interface{}) error {
    src := srcRaw.(*v1.Device)
    // Implement reverse conversion...
    return nil
}

Clients receiving unexpected format

Cause: Conversion middleware not configured or client not specifying version.

Solution: Ensure:

  1. Converters are properly generated (fabrica generate)
  2. Client is sending apiVersion in request
  3. Version middleware is registered in server setup

See Also