Skip to content

net/http Driver

The standard-library HTTP driver adapts generated HTTP and WebSocket routes to net/http.

Install it:

Terminal window
go get github.com/TDB-Group/anvil-http/std

Import it as:

import httpstd "github.com/TDB-Group/anvil-http/std"
app := anvil.New(
httpstd.Driver(),
)

When installed through anvil.New, the driver registers its HTTP and WebSocket capabilities with Anvil. Anvil’s edge listener accepts requests and dispatches HTTP fallback traffic to this driver.

The same driver can also run standalone through httpstd.New, but normal Anvil applications let Anvil own the public listener.

The driver accepts zero options or one httpstd.Options value:

app := anvil.New(
httpstd.Driver(httpstd.Options{
MaxBodyBytes: 8 << 20,
}),
)

Supported options:

OptionBehavior
ServerCloned *http.Server settings for standalone driver use. Handler must be nil because the driver installs it. In a normal edge app, public listener settings come from anvil.WithListener.
CodecsRequest decoder and response encoder registry. Defaults to Anvil’s default codec registry.
ErrorPipelineError mapper and observer pipeline. Driver fills this from app.ErrorPipeline() when the option is nil.
TLSCert/key pair used when the driver is run standalone. In a normal edge app, use app.ListenTLS.
WebSocketOrigin patterns and insecure origin bypass for WebSocket upgrades.
MaxBodyBytesMaximum request body size. 0 selects the driver default of 4 MiB; negative values are rejected. Set a larger value for upload endpoints.

The driver rejects negative timeouts, negative body limits, incomplete TLS configuration, nil codec registries, a server with a pre-filled Handler, and more than one options value.

DriverFrom installs an existing transport and sets that transport’s error pipeline to the owning app pipeline. Use httpstd.New(httpstd.Options{ErrorPipeline: ...}) when the transport is running standalone or when a test needs to inspect the transport before it is installed.

MaxBodyBytes wraps the request body with http.MaxBytesReader when the configured value is greater than zero. The option cannot disable the default limit by setting 0; 0 means “use the default.” The limit is observed when handler code or generated binding reads the body. Decode maps a limit error to 413 Request Entity Too Large; Body() returns nil if the body cannot be read because the SDK body accessor has no error return.

Server and TLS are only used when the standard driver runs its own standalone server. In the normal Anvil runtime, edge serves the public listener and calls the driver as an http.Handler. Codec settings, body limits, WebSocket origin policy, route matching, and response behavior still belong to the driver.

Use anvil.WithListener for public listener settings in an edge app:

app := anvil.New(
anvil.WithListener(&http.Server{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}),
httpstd.Driver(),
)

When a custom Server leaves timeout fields empty, the driver applies these defaults to the cloned server:

Server settingDefault
ReadHeaderTimeout5s
ReadTimeout30s
WriteTimeout30s
IdleTimeout2m

Generated routes are registered with RegisterHTTP.

Generated code performs route-collision checks before the driver sees a route. The standard driver validates the registered route shape, then optimizes the runtime match path. It is not the compiler and it does not try to explain conflicting source declarations.

Static routes are stored in a map keyed by method and normalized path. Parameter routes use segment matching and only allocate parameter storage when the route actually contains :params.

If code manually registers duplicate routes against the transport, the driver does not produce compiler-style collision diagnostics. A duplicate static route with the same method and normalized path replaces the previous static route in the map. Duplicate parameter routes are appended, and the first matching route in registration order handles the request. Generated Anvil code is checked for collisions before emission, so duplicate generated routes fail at compile time.

For matching, the standard driver trims outer slashes from registered patterns and request paths. /api/projects and /api/projects/ match the same route. It does not clean or collapse interior path segments; write the route path you intend to serve.

Only the URL path participates in route matching. Query strings are available through ctx.Request().Query(name) after a route has matched.

Route params use Go web router style:

type Routes struct {
Get sdk.GET `path:"/:projectId"`
}

{projectId} is not Anvil route syntax.

Handlers return the body as the normal Go return value. Response metadata lives on ctx.Response():

ctx.Response().Status(http.StatusCreated)
ctx.Response().Header("Location", "/projects/"+created.ID.String())
return projectDTO(created), nil

If the handler returns nil and no status was set, the driver sends 204 No Content. If a body is returned and no status was set, the driver sends 200 OK.

Returned bodies are encoded through the codec registry using the request Accept header.

Invalid response metadata is treated as a handler error. For example, an empty header name or a status code outside 100..999 is sent through the same error pipeline as a returned error.

Mapped HTTP failures are encoded through the same codec registry and request Accept header as normal responses. If that error response cannot be encoded, the driver falls back to HTTP 500 with a fixed JSON internal-error body.

ctx.Response().Stream writes through http.ResponseWriter and flushes when the writer supports http.Flusher.

The stream handler cannot return a separate response body. If both a stream and body are set, the driver maps that as an error.

The driver writes the response status when the stream first writes or flushes. If the stream never writes, the driver still commits the configured status, or 200 OK when no status was set. A nil stream handler is rejected immediately. Calling Stream a second time returns an error immediately and keeps the first stream handler. Portable handlers set stream status and headers through ctx.Response() before the stream starts. The SDK stream contract itself only exposes Context, Write, and Flush.

Stream errors follow normal Anvil error mapping, but HTTP response commitment still follows net/http rules. If the stream returns an error before the first write or flush, the driver can still write a mapped error response. If the stream has already written or flushed, the status and headers are committed. The driver still maps and publishes the failure, but the client may receive a partial stream instead of a clean JSON error body.

sdk.WS routes use the driver’s WebSocket integration. HTTP middleware runs before the upgrade, which is where auth and rate limits belong.

Origin policy is controlled by Options.WebSocket.

The standard driver only checks WebSocket routes when the request contains WebSocket upgrade headers. The socket library validates the handshake during accept. A normal HTTP request to a WebSocket-only path is not a match and returns 404.

Before the upgrade, handler and middleware errors are mapped into normal HTTP responses. After the upgrade succeeds, the driver maps the failure, publishes the error event, and closes the socket; there is no HTTP response left to write.

The driver uses github.com/coder/websocket. socket.Native() returns *websocket.Conn. The portable socket.Write method supports text and binary messages; use socket.Close for close frames. Reads from the portable socket expose text and binary messages. Other frame types are driver-level behavior unless the handler deliberately uses socket.Native().

ctx.Native() returns:

httpstd.NativeContext{
ResponseWriter: writer,
Request: request,
}

Using it ties the handler to this driver. Keep it for deliberate escape hatches, not normal application flow.

httpstd.New creates the transport directly. DriverFrom installs an existing transport into an app:

transport := httpstd.New()
app := anvil.New(httpstd.DriverFrom(transport))

This is useful for tests and low-level integration work.

Standalone mode can call transport.Start(addr) or transport.ServeConn(conn). Edge mode uses the transport as an http.Handler, so public listening and TLS termination stay in Anvil.

Standalone behavior is intentionally thin:

  • Start(addr) sets the cloned server address and calls ListenAndServe or ListenAndServeTLS.
  • ServeConn(conn) serves one accepted connection and normalizes http.ErrServerClosed to nil.
  • Shutdown(ctx) calls the cloned server’s Shutdown and normalizes http.ErrServerClosed to nil.
  • ServeHTTP is the path used by Anvil edge after request classification.

httpstd.RunTestbed(ctx, suite, opts) executes HTTP, GraphQL-over-HTTP, and WebSocket cases against a running server. Anvil defines the protocol-neutral testbed model, but the standard driver executes WebSocket cases because it imports the socket client.

The WebSocket runner converts http URLs to ws and https URLs to wss. It supports scripted text, binary, ping, and close input messages. It asserts text and binary output messages, then checks the optional close code. It does not assert ping or pong output frames.

Each WebSocket case runs with a 10s timeout inside the driver runner. An expected close code of 0 skips close-status assertion. Any non-zero close code makes the runner read one more frame and compare the WebSocket close status.

Use it when a generated suite contains WebSocket cases:

report, err := httpstd.RunTestbed(ctx, suite, testbed.RunOptions{
BaseURL: "http://127.0.0.1:8080",
})