Getting Started: Benzene on Google Cloud

This guide takes you from a message handler to a Benzene service running on Google Cloud, in idiomatic Go. The handler you write is the same transport-agnostic function every host runs — only the entry point and the deploy command are Google-specific.

Google Cloud gives Benzene-go two hosting shapes, and this guide walks through both from a runnable example:

  1. Cloud Run — a container listening on $PORT, serving the httpbinding REST handler. No Google-specific Go package is needed: Cloud Run's whole contract is "listen on $PORT", which net/http already satisfies. Follows examples/gcp-cloudrun-helloworld.
  2. Pub/Sub push — the same handler consuming a Pub/Sub push subscription, hosted as a Cloud Run service, via the gcppubsub binding. Follows examples/gcp-pubsub-helloworld.

Why Cloud Run and not Cloud Functions? Cloud Functions (2nd gen) is built on Cloud Run under the hood. Deploying straight to Cloud Run gets the same scale-to-zero autoscaling with one fewer layer, and without the functions-framework-go buildpack dependency — the one Google-specific dependency this port avoids everywhere else. See the Cloud Run example's README for the full reasoning.

Prerequisites

Add the module to your project:

go get github.com/daniellepelley/benzene-go

1. Cloud Run: the HTTP binding in a container

Cloud Run runs your container and routes HTTP requests to it. Because Benzene's httpbinding.Handler is an ordinary http.Handler, there is nothing Google-specific in the code — you write the same service you'd run anywhere, containerize it, and deploy.

The handler

The handler knows nothing about Google Cloud. It takes a request, returns a benzene.Result[T]:

type greetRequest struct {
	Name string `json:"name"`
}

type greetResponse struct {
	Greeting string `json:"greeting"`
}

func greetHandler(_ context.Context, req greetRequest) benzene.Result[greetResponse] {
	if req.Name == "" {
		return benzene.BadRequest[greetResponse]("name is required")
	}
	return benzene.Ok(greetResponse{Greeting: "Hello, " + req.Name + "!"})
}

benzene.Ok and benzene.BadRequest produce a Result[T] carrying a Benzene status; the HTTP binding maps that status to a real HTTP status code (200, 400, …) on the way out.

Wiring: registry, pipeline, route table

Register the handler against a topic, build a pipeline, and hand the whole thing to the HTTP binding. This example composes the ApplicationBuilder directly (a small service with no configuration to load):

func newApp() *benzene.ApplicationBuilder {
	registry := benzene.NewRegistry()
	if err := benzene.Register(registry, benzene.NewTopic("greet"), benzene.Handler[greetRequest, greetResponse](greetHandler)); err != nil {
		log.Fatalf("register greet handler: %v", err)
	}
	return &benzene.ApplicationBuilder{
		Registry:  registry,
		Container: benzene.NewContainer(),
		Pipeline:  benzene.NewPipeline(benzene.RouterMiddleware(registry)),
	}
}

func newHandler(builder *benzene.ApplicationBuilder) http.Handler {
	routes := []httpbinding.Route{{Method: http.MethodPost, Path: "/greet", Topic: benzene.NewTopic("greet")}}
	return httpbinding.Handler(builder, routes)
}

httpbinding.Route is the explicit route table: it maps an HTTP method + path to a Benzene topic. httpbinding.Handler returns the http.Handler that dispatches matching requests through RouterMiddleware to your registered handler.

The entry point: listen on $PORT

Cloud Run's one contract is that your process listens on the port named by the $PORT environment variable. main reads it (defaulting to 8080 for local runs) and starts a standard net/http server:

func portFromEnv() string {
	if port := os.Getenv("PORT"); port != "" {
		return port
	}
	return "8080"
}

func main() {
	handler := newHandler(newApp())
	port := portFromEnv()
	log.Printf("gcp-cloudrun-helloworld listening on :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, handler))
}

Run it locally

go run ./examples/gcp-cloudrun-helloworld
# gcp-cloudrun-helloworld listening on :8080

curl -X POST localhost:8080/greet -d '{"name":"World"}'
# {"greeting":"Hello, World!"}

curl -X POST localhost:8080/greet -d '{"name":""}'
# 400 Bad Request

Test it locally

The whole server runs in-process behind httptest.NewServer — no Docker, no cloud. Boot the real newHandler(newApp()) and push a request through the front door:

func TestGreetEndpoint_ReturnsGreeting(t *testing.T) {
	server := httptest.NewServer(newHandler(newApp()))
	defer server.Close()

	resp, err := http.Post(server.URL+"/greet", "application/json", strings.NewReader(`{"name":"World"}`))
	if err != nil {
		t.Fatalf("http.Post() error = %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
	}
	// ... decode resp.Body into greetResponse and assert "Hello, World!"
}
go test ./examples/gcp-cloudrun-helloworld/...

Containerize and deploy

A small multi-stage build produces a static binary on a distroless base. The build context must be the repo root, because the example imports sibling packages in the module:

FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod ./
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./examples/gcp-cloudrun-helloworld

# Cloud Run's only contract is "listen on $PORT" - a minimal static base is enough.
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

Build, push to Artifact Registry, and deploy:

# From the repo root - the build context needs the whole module.
docker build -f examples/gcp-cloudrun-helloworld/Dockerfile -t gcp-cloudrun-helloworld .

docker tag gcp-cloudrun-helloworld <region>-docker.pkg.dev/<project>/<repo>/gcp-cloudrun-helloworld
docker push <region>-docker.pkg.dev/<project>/<repo>/gcp-cloudrun-helloworld

gcloud run deploy gcp-cloudrun-helloworld \
  --image <region>-docker.pkg.dev/<project>/<repo>/gcp-cloudrun-helloworld \
  --region <region> \
  --allow-unauthenticated

When it finishes, gcloud prints the service URL. Call it exactly as you did locally:

SERVICE_URL=$(gcloud run services describe gcp-cloudrun-helloworld --region <region> --format 'value(status.url)')
curl -X POST "$SERVICE_URL/greet" -d '{"name":"World"}'
# {"greeting":"Hello, World!"}

gcloud run deploy --source . is a common shortcut, but it uses its source directory as both the Docker build context and the place it looks for the Dockerfile. Since this example's Dockerfile needs the whole module as context, the explicit build/push/deploy above is what works here.


2. Pub/Sub push via gcppubsub

A Pub/Sub push subscription delivers each message as an HTTPS POST to your service — no polling, no SDK. The gcppubsub binding decodes that push envelope (base64 data + attributes), resolves the topic per wire-contracts §2, and turns the dispatch result into an acknowledgement: 204 acks, 500 nacks (and Pub/Sub redelivers per the subscription's retry policy, dead-lettering if one is configured).

This half is consumer-only. Publishing needs the Pub/Sub SDK — a dependency this repo hasn't taken — so you publish with gcloud pubsub topics publish, no code required. The service still runs on Cloud Run; the difference from part 1 is what's mounted at the endpoint.

The handler

A push delivery is fire-and-forget: there is no caller to return a body to. The handler does its work (here, a log line visible in Cloud Logging), and the result's status decides ack vs nack-and-redeliver:

func greetHandler(_ context.Context, req greetRequest) benzene.Result[greetResponse] {
	if req.Name == "" {
		return benzene.BadRequest[greetResponse]("name is required")
	}
	greeting := "Hello, " + req.Name + "!"
	log.Printf("greeted: %s", greeting)
	return benzene.Ok(greetResponse{Greeting: greeting})
}

Wiring: the three-phase App

This example uses the full three-phase App lifecycle — the same composition root its tests boot from, which is what makes the front-door test below possible:

func newApp() benzene.App[struct{}] {
	return benzene.App[struct{}]{
		GetConfiguration: func() struct{} { return struct{}{} },
		ConfigureServices: func(registry *benzene.Registry, _ *benzene.Container, _ struct{}) {
			if err := benzene.Register(registry, benzene.NewTopic("greet"), benzene.Handler[greetRequest, greetResponse](greetHandler)); err != nil {
				log.Fatalf("register greet handler: %v", err)
			}
		},
		Configure: func(builder *benzene.ApplicationBuilder, _ struct{}) {
			builder.UsePipeline(benzene.NewPipeline(benzene.RouterMiddleware(builder.Registry)))
		},
	}
}

There's no configuration here, so TConfig is struct{}. App.Run() executes the three phases in order and returns the built *benzene.ApplicationBuilder.

The entry point: mount the push handler on a route

gcppubsub.Handler(builder) is an http.Handler. Mount it at the path the subscription will point at — /pubsub here — behind a ServeMux so nothing else reaches the dispatch pipeline:

func newHandler(builder *benzene.ApplicationBuilder) http.Handler {
	mux := http.NewServeMux()
	mux.Handle("/pubsub", gcppubsub.Handler(builder))
	return mux
}

func main() {
	handler := newHandler(newApp().Run())
	port := portFromEnv()
	log.Printf("gcp-pubsub-helloworld listening on :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, handler))
}

portFromEnv is the same $PORT reader as part 1 — this is a Cloud Run service too.

Test it locally

You don't need a real subscription to test the consumer. benzenetest.NewHost boots the real App, and benzenetest.SendPubSub pushes a native Pub/Sub push delivery in the front door, returning the HTTP acknowledgement so you can assert ack (204) vs nack (500):

func TestPushEndpoint_GreetMessageIsAcked(t *testing.T) {
	host := benzenetest.NewHost(newApp())

	resp := benzenetest.SendPubSub(t, host, benzene.NewTopic("greet"), greetRequest{Name: "World"}, nil)

	if resp.StatusCode != http.StatusNoContent { // 204 = ack
		t.Errorf("status = %d, want %d (ack)", resp.StatusCode, http.StatusNoContent)
	}
}

func TestPushEndpoint_FailedMessageIsNacked(t *testing.T) {
	host := benzenetest.NewHost(newApp())

	resp := benzenetest.SendPubSub(t, host, benzene.NewTopic("greet"), greetRequest{Name: ""}, nil)

	if resp.StatusCode != http.StatusInternalServerError { // 500 = nack, redeliver
		t.Errorf("status = %d, want %d (nack)", resp.StatusCode, http.StatusInternalServerError)
	}
}
go test ./examples/gcp-pubsub-helloworld/...

Deploy, then create the topic and subscription

The Dockerfile is identical in shape to part 1, only pointed at ./examples/gcp-pubsub-helloworld. Build, push, and deploy the Cloud Run service the same way, then create the Pub/Sub topic and a push subscription that targets the service's /pubsub endpoint:

# From the repo root.
docker build -f examples/gcp-pubsub-helloworld/Dockerfile -t gcp-pubsub-helloworld .
docker tag gcp-pubsub-helloworld <region>-docker.pkg.dev/<project>/<repo>/gcp-pubsub-helloworld
docker push <region>-docker.pkg.dev/<project>/<repo>/gcp-pubsub-helloworld

gcloud run deploy gcp-pubsub-helloworld \
  --image <region>-docker.pkg.dev/<project>/<repo>/gcp-pubsub-helloworld \
  --region <region> \
  --allow-unauthenticated

gcloud pubsub topics create greet-helloworld
SERVICE_URL=$(gcloud run services describe gcp-pubsub-helloworld --region <region> --format 'value(status.url)')
gcloud pubsub subscriptions create greet-helloworld-push \
  --topic greet-helloworld \
  --push-endpoint "$SERVICE_URL/pubsub"

--allow-unauthenticated keeps this demo minimal. For anything real, deploy without it and give the subscription a push auth service account instead (gcloud pubsub subscriptions create --push-auth-service-account=...). Pub/Sub then attaches an OIDC token that Cloud Run verifies, locking the endpoint to Pub/Sub — no code change here.

Try it

Publish a message. The topic attribute carries the Benzene topic (per wire-contracts §2); the message body is the handler's request JSON:

gcloud pubsub topics publish greet-helloworld \
  --message '{"name":"World"}' \
  --attribute topic=greet

# The observable effect is the log line in Cloud Logging:
gcloud run services logs read gcp-pubsub-helloworld --region <region> --limit 5
# ... greeted: Hello, World!

A message that fails (--message '{"name":""}') is nacked with a 500 and redelivered per the subscription's retry policy. Add --dead-letter-topic to the subscription to cap the redeliveries.


Next steps