For AI agents: the complete documentation index is available at /hclapi/llms.txt, the full documentation bundle is available at /hclapi/llms-full.txt, and this page is available as Markdown at /hclapi/guides/go.md.

Go integration

hclapi embeds into an existing Go application as a library. It mounts native HTTP handlers alongside declarative routes and supports calling back into native Go functions from pipeline steps.

Embedding

hclapi.Engine implements the standard http.Handler interface. It mounts directly onto any http.ServeMux, middleware stack, or third-party router.

Basic setup

package main

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/ju4n97/hclapi"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    engine, err := hclapi.NewEngine(hclapi.Options{
        ConfigPath:   "./manifests",
        StrictTyping: true,
        Logger:       logger,
    })
    if err != nil {
        logger.Error("failed to initialize hclapi engine", "error", err)
        os.Exit(1)
    }

    mux := http.NewServeMux()

    // Native custom endpoint
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })

    // Mount hclapi router
    mux.Handle("/", engine.Handler())

    serverConfig := engine.Server()
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  serverConfig.ReadTimeout.Duration(),
        WriteTimeout: serverConfig.WriteTimeout.Duration(),
        IdleTimeout:  serverConfig.IdleTimeout.Duration(),
    }

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

    go func() {
        logger.Info("server listening", "addr", server.Addr)
        if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            logger.Error("server error", "error", err)
            os.Exit(1)
        }
    }()

    <-stop
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    server.Shutdown(ctx)
}

Registering steps

Register a Go function that an HCL go step can invoke.

Signature

type StepHandler func(ctx context.Context, step *hclapi.Step) (any, error)

err := engine.RegisterStep("namespace.function_name", handler)
  • ctx: Standard Go context.Context carrying request cancellation, timeouts, and tracing spans. Pass this directly to outbound HTTP requests, database queries, and cache calls.
  • step: The step execution handle carrying evaluated inputs (step.Args), request metadata (step.Request), and outputs of prior steps (step.Steps).

Step data access

*hclapi.Step provides thread-safe access to execution data:

Field / MethodType / ReturnDescription
ctxcontext.ContextCancellation context passed directly into standard library IO calls
step.Argshclapi.ArgsEvaluated arguments defined in the manifest args = { ... } block
step.Request*hclapi.RequestStateIngress HTTP metadata (Method, Path, Query, Headers, Body)
step.Request.Header(key)stringCase-insensitive header lookup
step.Request.PathParam(key)stringRoute path parameter lookup
step.Request.QueryParam(key)stringQuery string parameter lookup with optional fallback
step.GetStepResult(name)(StepResult, bool)Safely retrieve outputs from a prior pipeline step
step.TimestampEpochint64Ingress Unix epoch timestamp
step.RawRequest*http.RequestThe underlying Go standard library request

Working with arguments (step.Args)

step.Args uses generic methods for type coercion, handling HCL integers (int64), floating-point values, and dynamic lists:


// Explicit typed extraction (returns zero value and false if missing or invalid)
latitude, ok := step.Args.Get[float64]("latitude")
if !ok {
    return nil, errors.New("missing or invalid 'latitude' argument")
}

// Generic type inference with defaults
port := step.Args.GetOr("port", 8080)          // Inferred as int
host := step.Args.GetOr("host", "localhost")   // Inferred as string
priority := step.Args.GetOr("priority", false) // Inferred as bool
timeout := step.Args.GetOr("timeout_s", 2.5)   // Inferred as float64

// Typed list extraction (safely coerces HCL dynamic []any slices)
tags := step.Args.Slice[string]("tags")
ids  := step.Args.Slice[int]("user_ids")

// Struct binding (unmarshals arguments directly into a Go struct)
var config ServiceConfig
if err := step.Args.Bind(&config); err != nil {
    return nil, fmt.Errorf("invalid arguments: %w", err)
}

Example: Outbound HTTP Call

1. In your Go application:

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "time"

    "github.com/ju4n97/hclapi"
)

type WeatherResponse struct {
    Current struct {
        Temperature float64 `json:"temperature_2m"`
        Humidity    int     `json:"relative_humidity_2m"`
    } `json:"current"`
}

var httpClient = &http.Client{Timeout: 5 * time.Second}

func registerWeatherStep(engine *hclapi.Engine) error {
    return engine.RegisterStep("services.fetch_weather", func(ctx context.Context, step *hclapi.Step) (any, error) {
        // Read arguments using type-safe getters
        lat, okLat := step.Args.Get[float64]("latitude")
        lon, okLon := step.Args.Get[float64]("longitude")
        if !okLat || !okLon {
            return nil, errors.New("missing or invalid 'latitude' or 'longitude' arguments")
        }

        url := fmt.Sprintf(
            "https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f&current=temperature_2m,relative_humidity_2m",
            lat, lon,
        )

        // Pass standard ctx directly to outgoing HTTP request for timeout/cancellation propagation
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, fmt.Errorf("failed to create request: %w", err)
        }

        resp, err := httpClient.Do(req)
        if err != nil {
            return nil, fmt.Errorf("external weather API request failed: %w", err)
        }
        defer resp.Body.Close()

        var data WeatherResponse
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return nil, fmt.Errorf("failed to decode weather API response: %w", err)
        }

        // Return a map, struct, or primitive. The output is exported under steps.<name>.result (steps.weather.result in this example)
        return map[string]any{
            "temperature_c": data.Current.Temperature,
            "humidity_pct":  data.Current.Humidity,
            "fetched_at":    step.TimestampEpoch,
        }, nil
    })
}

2. In your HCL manifest:

endpoint "GET /api/v1/locations/{city}/weather" {
  pipeline {
    sql "get_coords" {
      connection = connection.postgres.main
      query      = "SELECT latitude, longitude FROM locations WHERE city = @city"
      args       = { city = ctx.request.path.city }
    }

    respond {
      condition = steps.get_coords.rows_affected == 0
      status    = 404
      body      = { error = "Location not found" }
    }

    go "weather" {
      use = "services.fetch_weather"
      args = {
        latitude  = steps.get_coords.row.latitude
        longitude = steps.get_coords.row.longitude
      }
    }

    respond {
      status = 200
      body = {
        city        = ctx.request.path.city
        temperature = steps.weather.result.temperature_c
        humidity    = steps.weather.result.humidity_pct
      }
    }
  }
}

Error handling and custom status codes

  • Standard Go errors: Returning a standard Go error (e.g. errors.New("db failure")) automatically returns an HTTP 500 with problem type urn:hclapi:error:pipeline-execution-failed.
  • Custom HTTP status codes: Return step.Problem(status, detail) or a hclapi.Problem struct to emit custom status codes (such as 401, 403, 404, or 429) directly to the client.
  • Panics: Any panic inside a StepHandler is automatically recovered by the runtime, logged with the step's name and stack trace, and returned as an HTTP 500. The server process continues running uninterrupted.