Skip to content

Serve on a go/transport server

transport-openapi mounts onto a plain *http.ServeMux, and a go/transport HTTP server takes an http.Handler. Register the docs on the mux you hand to the transport server and they are served alongside your API — same process, same port, same origin.

Mount the docs on the mux you pass to the server

import (
    "context"
    "net/http"

    transporthttp "gitlab.com/phpboyscout/go/transport/http"
    openapi "gitlab.com/phpboyscout/go/transport-openapi"
)

//go:embed openapi.yaml
var spec []byte

func newServer(ctx context.Context) (*http.Server, error) {
    mux := http.NewServeMux()

    // API handlers…
    mux.HandleFunc("GET /v1/things", listThings)

    // API docs on the same mux.
    if err := openapi.Register(mux, spec,
        openapi.WithSpecPath("/v1/openapi.yaml"),
        openapi.WithDocsPath("/v1/docs/"),
        openapi.WithTitle("Things API v1"),
    ); err != nil {
        return nil, err
    }

    return transporthttp.NewServer(ctx, transporthttp.ServerSettings{Port: 8443}, mux)
}

NewServer returns a configured *http.Server — the standard-library type, not a transport-specific one:

func NewServer(ctx context.Context, settings ServerSettings, handler http.Handler, opts ...ServerOption) (*http.Server, error)

ServerSettings carries Host, Port and MaxHeaderBytes. Timeouts, TLS and bind address come from ServerOption values such as WithReadTimeout and WithServedCertificate.

Put it under the controls lifecycle instead

transporthttp.Register builds the same server and registers its start, stop and status with a go/controls controller, so graceful shutdown and health reporting are handled for you:

func newSupervised(ctx context.Context, controller *controls.Controller, logger *slog.Logger) (*http.Server, error) {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /v1/things", listThings)

    if err := openapi.Register(mux, spec,
        openapi.WithSpecPath("/v1/openapi.yaml"),
        openapi.WithDocsPath("/v1/docs/"),
    ); err != nil {
        return nil, err
    }

    return transporthttp.Register(ctx, "api", controller, logger, mux,
        transporthttp.ServerSettings{Port: 8443})
}

Your mux becomes the server's / handler, so /v1/docs/ is a first-class route on it and the transport server's lifecycle covers the docs as well as the API.

Paths the transport server takes for itself

transporthttp.Register wraps your handler in an outer mux of its own, which claims three exact paths before yours are consulted:

Path Served by
/healthz transporthttp.HealthHandler
/livez transporthttp.LivenessHandler
/readyz transporthttp.ReadinessHandler

These are exact matches, so only an exact collision is shadowed. A spec at WithSpecPath("/healthz") would never be reached; a docs prefix of WithDocsPath("/healthz/") still works, because /healthz/ is a different pattern from /healthz. Avoid all three anyway — the confusion is not worth the address space.

Register also applies a request-body cap of DefaultMaxRequestBodyBytes, 1 MiB, across the whole surface. The docs routes answer GET and HEAD only, so it never affects them; it applies to the API calls the try-it console makes.

Why the same server

Serving the docs from the transport server rather than a separate static host keeps the spec and the try-it console same-origin with the API they document, so the console can call your endpoints without any CORS configuration. See Same-origin docs by design.

Avoid setting security headers twice

openapi.Register applies go/transport's security-header middleware to the docs and spec handlers by default. If your transport server already wraps the whole mux in an equivalent chain — a transit http.Chain passed as transporthttp.WithMiddleware — opt the docs handlers out with WithoutSecurityHeaders so the values have one owner.