Chio/Docs
LOGIN · JOIN

BuildHTTP Frameworks

Go and C++ HTTP Frameworks

Protect Go and C++ HTTP routes with a local Chio sidecar using Chi and Drogon middleware.

What it shows

  • chio-go-http: a single chio.Protect(handler, opts...) call that wraps any http.Handler. The example uses chi as the inner router; it works equally well with net/http, gorilla/mux, or any handler.
  • chio-drogon: a Drogon middleware named chio::drogon::ChioMiddleware attached to specific routes plus a chio::drogon::receipt_id(request) accessor inside handlers.
  • The C++ smoke verifies that the receipt content hash is bound to the exact raw JSON bytes sent by the client.
  • Both examples talk to a local chio api protect sidecar.

Drogon is optional

hello-drogon is gated by CMake. If cmake is missing or Drogon::Drogon cannot be found, both run.sh and smoke.sh stop at configure time, print the reason, and exit 0. Install Drogon, or set CMAKE_PREFIX_PATH, before running the example.
hello-drogon · smoke.sh without Drogontranscript
$ ./smoke.sh
hello-drogon smoke skipped: Drogon::Drogon was not found. Install Drogon or set CMAKE_PREFIX_PATH to its CMake package directory.
exit 0

Prerequisites

  • Go: Go 1.21+ (see go.mod).
  • C++: a C++17 compiler, CMake 3.16+, and the Drogon framework. The example pulls sdks/cpp/chio-drogon via add_subdirectory.
  • A Chio checkout, plus either a Rust toolchain or CHIO_BIN pointing at a built binary. Both smokes source examples/_shared/hello-http-common.sh, whose ensure_chio_bin() never consults PATH: it uses $CHIO_BIN when that is set and executable, otherwise target/debug/chio under the checkout, and runs cargo build --bin chio when that is missing (hello-http-common.sh:81-96). An installed chio with no checkout runs neither smoke. Both start a local sidecar and trust service from that binary.

Run them

bash
cd examples/hello-chi   # or hello-drogon
./run.sh

# Full smoke (sidecar + trust + deny + allow)
./smoke.sh
hello-chi · summarytranscript
$ ./smoke.sh
hello-chi smoke passed
artifacts: <chio-source>/examples/hello-chi/.artifacts/20260905T114404Z
hello receipt: 25fa66e5413d596601b708653d86b3f3a7af20cf12c269b7d5bd2b3c60dba661
deny receipt: 04c22cdfd0b4c28d85838f715d6134ac11314294682cb6367330f0ffe0bccdaa
allow receipt: 9b1ccab8d86f446fb9571c8d8e4900b15e4c2df5e47f4cbe6633dbe8084b26d0
exit 0

Default ports:

ExampleEnv varDefault
hello-chiHELLO_CHI_PORT8013
hello-drogonHELLO_DROGON_PORT8020

Go (chi)

chio.Protect wraps any http.Handler and returns a new http.Handler. The chi router goes inside that wrapper unchanged; you keep your normal routing, middleware, and handler signatures.

Module

examples/hello-chi/go.modtext
module hello-chi

go 1.21

require (
	github.com/backbay-labs/chio/sdks/go/chio-go-http v0.0.0
	github.com/go-chi/chi/v5 v5.2.3
)

require (
	github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/oapi-codegen/runtime v1.2.0 // indirect
)

replace github.com/backbay-labs/chio/sdks/go/chio-go-http => ../../sdks/go/chio-go-http

Server

examples/hello-chi/main.gogo
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"

	chio "github.com/backbay-labs/chio/sdks/go/chio-go-http"
	"github.com/go-chi/chi/v5"
)

type echoRequest struct {
	Message *string `json:"message"`
	Count   *int    `json:"count,omitempty"`
}

type echoResponse struct {
	Message string `json:"message"`
	Count   int    `json:"count"`
}

func main() {
	handler := protectedHandler(envOrDefault("CHIO_SIDECAR_URL", "http://127.0.0.1:9090"))
	addr := "127.0.0.1:" + envOrDefault("HELLO_CHI_PORT", "8013")
	log.Printf("hello-chi listening on http://%s", addr)
	if err := http.ListenAndServe(addr, handler); err != nil {
		log.Fatal(err)
	}
}

func protectedHandler(sidecarURL string) http.Handler {
	return chio.Protect(
		newRouter(),
		chio.WithSidecarURL(sidecarURL),
	)
}

func newRouter() http.Handler {
	router := chi.NewRouter()

	router.Get("/healthz", healthz)
	router.Get("/hello", hello)
	router.Post("/echo", echo)

	return router
}

func healthz(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func hello(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"message": "hello from chi"})
}

func echo(w http.ResponseWriter, r *http.Request) {
	payload, err := parseEchoRequest(r.Body)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}

	writeJSON(w, http.StatusOK, payload)
}

func parseEchoRequest(body io.Reader) (echoResponse, error) {
	decoder := json.NewDecoder(body)
	decoder.DisallowUnknownFields()

	var payload echoRequest
	if err := decoder.Decode(&payload); err != nil {
		return echoResponse{}, err
	}

	var trailing struct{}
	if err := decoder.Decode(&trailing); err != io.EOF {
		return echoResponse{}, fmt.Errorf("body must contain a single JSON object")
	}

	if payload.Message == nil || *payload.Message == "" {
		return echoResponse{}, fmt.Errorf("message must be a non-empty string")
	}

	count := 1
	if payload.Count != nil {
		count = *payload.Count
	}
	if count < 1 {
		return echoResponse{}, fmt.Errorf("count must be an integer greater than or equal to 1")
	}

	return echoResponse{
		Message: *payload.Message,
		Count:   count,
	}, nil
}

func envOrDefault(name, fallback string) string {
	value := os.Getenv(name)
	if value == "" {
		return fallback
	}
	return value
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

chio.Protect returns an http.Handler, so it composes with any other Go HTTP middleware. Every option has a default: defaultConfig() reads CHIO_SIDECAR_URL and falls back to http://127.0.0.1:9090, with a five-second timeout, the default identity extractor and the raw path as the route pattern (sdks/go/chio-go-http/config.go:41-57), so chio.Protect(router) with no options is a working call. The example passes chio.WithSidecarURL explicitly so the smoke can point it at a scratch port.

The receipt id reaches the caller on the response header only. The middleware sets X-Chio-Receipt-Id and then calls the inner handler with the original request (sdks/go/chio-go-http/chio.go:133-136); there is no request-context carrier, so a handler that needs the id has to read it back off the http.ResponseWriter.

/echo is stricter than it looks. parseEchoRequest sets DisallowUnknownFields(), rejects a second JSON object in the body, requires a non-empty message, and requires count to be at least 1, returning 400 otherwise. That is the contract the sidecar governs in front of.

Why not cgo? The Go SDK uses pure HTTP to the sidecar. That keeps cross-compilation, static linking, and Windows builds working without a cgo dependency.


C++ (Drogon)

Drogon registers handlers by name and accepts a list of named middlewares per handler. Add "chio::drogon::ChioMiddleware" to the list for any route you want governed.

CMake

examples/hello-drogon/CMakeLists.txttext
cmake_minimum_required(VERSION 3.16)

project(HelloDrogon LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

set(HELLO_DROGON_SKIP_FILE "${CMAKE_BINARY_DIR}/hello-drogon.skip")
set(HELLO_DROGON_READY_FILE "${CMAKE_BINARY_DIR}/hello-drogon.ready")
file(REMOVE "${HELLO_DROGON_SKIP_FILE}" "${HELLO_DROGON_READY_FILE}")

find_package(Drogon CONFIG QUIET)
if(NOT TARGET Drogon::Drogon)
  file(WRITE "${HELLO_DROGON_SKIP_FILE}"
       "Drogon::Drogon was not found. Install Drogon or set CMAKE_PREFIX_PATH to its CMake package directory.\n")
  message(STATUS "Drogon::Drogon was not found; skipping hello-drogon example")
  return()
endif()

set(CHIO_DROGON_BUILD_TESTS OFF CACHE BOOL "Build chio-drogon tests" FORCE)
set(CHIO_DROGON_REQUIRE_DEPS OFF CACHE BOOL "Require chio-drogon deps" FORCE)
add_subdirectory(
  "${CMAKE_CURRENT_SOURCE_DIR}/../../sdks/cpp/chio-drogon"
  "${CMAKE_CURRENT_BINARY_DIR}/chio-drogon"
  EXCLUDE_FROM_ALL
)

if(NOT TARGET ChioDrogon::chio_drogon)
  file(WRITE "${HELLO_DROGON_SKIP_FILE}"
       "ChioDrogon::chio_drogon was not available after configuring sdks/cpp/chio-drogon.\n")
  message(STATUS "ChioDrogon::chio_drogon was not available; skipping hello-drogon example")
  return()
endif()

add_library(hello_drogon_app
  src/hello_app.cpp
)
target_include_directories(hello_drogon_app PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(hello_drogon_app PUBLIC ChioDrogon::chio_drogon)
target_compile_features(hello_drogon_app PUBLIC cxx_std_17)

add_executable(hello_drogon main.cpp)
target_link_libraries(hello_drogon PRIVATE hello_drogon_app)
target_compile_features(hello_drogon PRIVATE cxx_std_17)

enable_testing()
add_executable(hello_drogon_contract_tests src/hello_app_test.cpp)
target_link_libraries(hello_drogon_contract_tests PRIVATE hello_drogon_app)
add_test(NAME hello_drogon_contract_tests COMMAND hello_drogon_contract_tests)

if(MSVC)
  target_compile_options(hello_drogon_app PRIVATE /W4)
  target_compile_options(hello_drogon PRIVATE /W4)
  target_compile_options(hello_drogon_contract_tests PRIVATE /W4)
else()
  target_compile_options(hello_drogon_app PRIVATE -Wall -Wextra -Wpedantic)
  target_compile_options(hello_drogon PRIVATE -Wall -Wextra -Wpedantic)
  target_compile_options(hello_drogon_contract_tests PRIVATE -Wall -Wextra -Wpedantic)
endif()

file(WRITE "${HELLO_DROGON_READY_FILE}" "hello_drogon target configured\n")

Two guards write a hello-drogon.skip file and return early: one when Drogon::Drogon is not found, one when ChioDrogon::chio_drogon is still missing after the SDK subdirectory is configured. Both run.sh and smoke.sh test for that file, print its contents and exit 0 (run.sh:21-24, smoke.sh:24-27). The routes and the contract tests live in a hello_drogon_app library and the executable links that, so main.cpp compiles against the same code the tests do.

Source

main.cpp is ten lines and holds no routing. It configures Chio from the environment, registers the routes, and starts Drogon:

examples/hello-drogon/main.cpptext
#include <drogon/drogon.h>

#include "hello_app.hpp"

int main() {
  hello_drogon::configure_chio_from_env();
  hello_drogon::register_routes();
  drogon::app().addListener("127.0.0.1", hello_drogon::port_from_env());
  drogon::app().run();
}

Both of those live in src/hello_app.cpp, which is where the middleware is configured:

examples/hello-drogon/src/hello_app.cpp89-94text
void configure_chio_from_env() {
  chio::drogon::Options options;
  options.sidecar_url = env_or_default("CHIO_SIDECAR_URL", "http://127.0.0.1:9090");
  options.sidecar_failure_mode = chio::drogon::SidecarFailureMode::FailClosed;
  chio::drogon::configure(std::move(options));
}

and where each route names it. The third argument to registerHandler is the HTTP method and the middleware names to apply, in order, so /healthz is ungoverned and the other two are not:

examples/hello-drogon/src/hello_app.cpp96-122text
void register_routes() {
  drogon::app().registerHandler(
      "/healthz",
      [](const drogon::HttpRequestPtr&,
         std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
        callback(json_response(health_body()));
      },
      {drogon::Get});

  drogon::app().registerHandler(
      "/hello",
      [](const drogon::HttpRequestPtr& request,
         std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
        callback(json_response(hello_body(chio::drogon::receipt_id(request))));
      },
      {drogon::Get, "chio::drogon::ChioMiddleware"});

  drogon::app().registerHandler(
      "/echo",
      [](const drogon::HttpRequestPtr& request,
         std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
        const auto payload = request->getJsonObject();
        const auto result = echo_body(payload.get(), chio::drogon::receipt_id(request));
        callback(json_response(result.body, result.status));
      },
      {drogon::Post, "chio::drogon::ChioMiddleware"});
}

SidecarFailureMode::FailClosed denies a request when the sidecar is unreachable. chio::drogon::receipt_id(request) returns the id the middleware assigned on this request, which is how the receipt reaches the response body as well as the header. echo_body (src/hello_app.cpp:56-79) returns 400 for a non-object body, a blank message, or a count below 1.


Critical wiring

One line each. On the Go side the chi router goes inside chio.Protect:

examples/hello-chi/main.go34-39go
func protectedHandler(sidecarURL string) http.Handler {
	return chio.Protect(
		newRouter(),
		chio.WithSidecarURL(sidecarURL),
	)
}

On the C++ side the middleware is not attached to the app, it is named in each handler's registration list: {drogon::Get, "chio::drogon::ChioMiddleware"} at examples/hello-drogon/src/hello_app.cpp:111 and the drogon::Post form at :121. A route whose list omits the name is not governed, which is why /healthz needs no capability.


Smoke assertions

The chi smoke makes three calls and checks each one inline. The middle one carries no capability and is expected to come back chio_access_denied with a receipt id; the third one carries the token the trust service just issued:

examples/hello-chi/smoke.sh72-121bash
curl -sS -D "${ARTIFACT_ROOT}/hello.headers" "${APP_URL}/hello" > "${ARTIFACT_ROOT}/hello.json"

python3 - "${ARTIFACT_ROOT}/hello.json" <<'PY'
import json
import sys
from pathlib import Path

body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["message"] == "hello from chi", body
PY

curl -sS -D "${ARTIFACT_ROOT}/deny.headers" \
  -H "content-type: application/json" \
  --data '{"message":"denied","count":1}' \
  "${APP_URL}/echo" \
  > "${ARTIFACT_ROOT}/deny.json"

python3 - "${ARTIFACT_ROOT}/deny.json" <<'PY'
import json
import sys
from pathlib import Path

body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["error"] == "chio_access_denied", body
assert body["receipt_id"], body
PY

issue_demo_capability \
  "${CONTROL_URL}" \
  "${SERVICE_TOKEN}" \
  "${ARTIFACT_ROOT}/capability.json" \
  "authorize_http_request" \
  "chio_http_authority"
materialize_capability_token "${ARTIFACT_ROOT}/capability.json" "${ARTIFACT_ROOT}/capability.token"

curl -sS -D "${ARTIFACT_ROOT}/allow.headers" \
  -H "content-type: application/json" \
  -H "X-Chio-Capability: $(tr -d '\n' < "${ARTIFACT_ROOT}/capability.token")" \
  --data '{"message":"hello","count":2}' \
  "${APP_URL}/echo" \
  > "${ARTIFACT_ROOT}/allow.json"

python3 - "${ARTIFACT_ROOT}/allow.json" <<'PY'
import json
import sys
from pathlib import Path

body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
assert body["message"] == "hello", body
assert body["count"] == 2, body

The Drogon smoke adds the check the C++ example exists for. It reads the persisted receipt out of the sidecar's SQLite store, re-derives the content hash from the raw POST bytes it sent, and compares:

examples/hello-drogon/smoke.sh240-252python
receipt = next((record for record in records if record.get("id") == allow_receipt_id), None)
assert receipt is not None, f"missing allow receipt {allow_receipt_id}"
body_hash = hashlib.sha256(raw_payload.encode("utf-8")).hexdigest()
binding = {
    "body_hash": body_hash,
    "method": "POST",
    "path": "/echo",
    "query": {},
    "route_pattern": "/echo",
}
content_hash = hashlib.sha256(
    json.dumps(binding, separators=(",", ":"), sort_keys=True).encode("utf-8")
).hexdigest()
examples/hello-drogon/smoke.sh253-257python
assert receipt["content_hash"] == content_hash, {
    "expected": content_hash,
    "actual": receipt["content_hash"],
    "body_hash": body_hash,
}

The binding is the sha256 of the canonical JSON of five fields, one of which is the sha256 of the request body. Byte-equality against the persisted receipt is what proves the receipt is bound to the exact bytes the client sent rather than to a re-serialization of them.

Inspect after

Each run writes into .artifacts/<timestamp>/. The allowed read carries its id on the response header:

hello-chi · allowtranscript
$ cat hello.headers hello.json
HTTP/1.1 200 OK
Content-Type: application/json
X-Chio-Receipt-Id: 25fa66e5413d596601b708653d86b3f3a7af20cf12c269b7d5bd2b3c60dba661
Date: Sat, 05 Sep 2026 11:44:09 GMT
Content-Length: 29

{"message":"hello from chi"}
exit 0

Three calls, three persisted receipts, and the bytes each one binds to:

hello-chi · receiptstranscript
$ wc -l receipts.ndjson
$ jq -r '.id, .verdict.verdict, .content_hash' receipts.ndjson
3 receipts.ndjson
25fa66e5413d596601b708653d86b3f3a7af20cf12c269b7d5bd2b3c60dba661
allow
4335a80844e3efdd91a02689423b83f817aec706e5c97b52ac7fbb80f0949b71
04c22cdfd0b4c28d85838f715d6134ac11314294682cb6367330f0ffe0bccdaa
deny
f66eb8b953cd504d0ec48c92b28e76d379375bd8452e4977acff00af2364e4df
9b1ccab8d86f446fb9571c8d8e4900b15e4c2df5e47f4cbe6633dbe8084b26d0
allow
d9a9f383f818915eaebb3a441a9995c3d09327796e93cf2266dbde0095e23a57
exit 0

A distinct content_hash on each: the deny binds to the request bytes it refused just as the two allows bind to the bytes they passed. The ids move every run; the three content hashes do not, because they are a function of the request and not of the clock. Chi returns the id on the header only, so allow.json is {"message":"hello","count":2} with no receipt_id field. On Drogon the same id also appears in the body, which is what the smoke's header-to-body equality check compares.

The same rows are in the sidecar's SQLite store, if you have sqlite3 and would rather read them there:

bash
sqlite3 state/sidecar-receipts.sqlite3 \
  "select id, json_extract(receipt_json, '$.content_hash'), json_extract(receipt_json, '$.verdict.verdict') from http_receipts;"

The denied call

The middle receipt is the POST /echo sent with no capability token. The sidecar refuses it before the Go handler runs:

hello-chi · denytranscript
$ cat deny.headers deny.json
HTTP/1.1 403 Forbidden
Content-Type: application/json
Date: Sat, 05 Sep 2026 11:44:09 GMT
Content-Length: 284

{"error":"chio_access_denied","message":"side-effect route requires a capability token","receipt_id":"04c22cdfd0b4c28d85838f715d6134ac11314294682cb6367330f0ffe0bccdaa","suggestion":"provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"}
exit 0

The receipt_id in that body is the deny row above, so the caller can hand its refusal id to an auditor and have it resolve.

When this fits

Use this when: your service is Go (chi, gin, gorilla, plain net/http) or Drogon, and you want cross-compile-safe binaries (Go, no cgo) or static-link (Drogon, via add_subdirectory). Don't use this if you would rather sidecar-front the service: see OpenAPI Sidecar. For managed runtimes see JVM and .NET.

Binding shape

AspectGo (chi)C++ (Drogon)
SDK packagegithub.com/backbay-labs/chio/sdks/go/chio-go-httpsdks/cpp/chio-drogon
Integration shapehttp.Handler wrapperDrogon named middleware per route
Linking modelPure Go; no cgoStatic link; add_subdirectory
Sidecar URLchio.WithSidecarURL(...) option, else CHIO_SIDECAR_URLchio::drogon::Options::sidecar_url
Receipt accessResponse header onlychio::drogon::receipt_id(request)
Failure modeConfigurable on the option setSidecarFailureMode::FailClosed in this example

Next