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 singlechio.Protect(handler, opts...)call that wraps anyhttp.Handler. The example useschias the inner router; it works equally well withnet/http,gorilla/mux, or any handler.chio-drogon: a Drogon middleware namedchio::drogon::ChioMiddlewareattached to specific routes plus achio::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 protectsidecar.
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.$ ./smoke.shhello-drogon smoke skipped: Drogon::Drogon was not found. Install Drogon or set CMAKE_PREFIX_PATH to its CMake package directory.
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-drogonviaadd_subdirectory. - A Chio checkout, plus either a Rust toolchain or
CHIO_BINpointing at a built binary. Both smokes sourceexamples/_shared/hello-http-common.sh, whoseensure_chio_bin()never consultsPATH: it uses$CHIO_BINwhen that is set and executable, otherwisetarget/debug/chiounder the checkout, and runscargo build --bin chiowhen that is missing (hello-http-common.sh:81-96). An installedchiowith no checkout runs neither smoke. Both start a local sidecar and trust service from that binary.
Run them
cd examples/hello-chi # or hello-drogon
./run.sh
# Full smoke (sidecar + trust + deny + allow)
./smoke.sh$ ./smoke.shhello-chi smoke passed artifacts: <chio-source>/examples/hello-chi/.artifacts/20260905T114404Z hello receipt: 25fa66e5413d596601b708653d86b3f3a7af20cf12c269b7d5bd2b3c60dba661 deny receipt: 04c22cdfd0b4c28d85838f715d6134ac11314294682cb6367330f0ffe0bccdaa allow receipt: 9b1ccab8d86f446fb9571c8d8e4900b15e4c2df5e47f4cbe6633dbe8084b26d0
Default ports:
| Example | Env var | Default |
|---|---|---|
hello-chi | HELLO_CHI_PORT | 8013 |
hello-drogon | HELLO_DROGON_PORT | 8020 |
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
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-httpServer
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
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:
#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:
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:
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:
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:
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, bodyThe 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:
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()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:
$ cat hello.headers hello.jsonHTTP/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"}Three calls, three persisted receipts, and the bytes each one binds to:
$ wc -l receipts.ndjson
$ jq -r '.id, .verdict.verdict, .content_hash' receipts.ndjson3 receipts.ndjson 25fa66e5413d596601b708653d86b3f3a7af20cf12c269b7d5bd2b3c60dba661 allow 4335a80844e3efdd91a02689423b83f817aec706e5c97b52ac7fbb80f0949b71 04c22cdfd0b4c28d85838f715d6134ac11314294682cb6367330f0ffe0bccdaa deny f66eb8b953cd504d0ec48c92b28e76d379375bd8452e4977acff00af2364e4df 9b1ccab8d86f446fb9571c8d8e4900b15e4c2df5e47f4cbe6633dbe8084b26d0 allow d9a9f383f818915eaebb3a441a9995c3d09327796e93cf2266dbde0095e23a57
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:
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:
$ cat deny.headers deny.jsonHTTP/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"}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
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
| Aspect | Go (chi) | C++ (Drogon) |
|---|---|---|
| SDK package | github.com/backbay-labs/chio/sdks/go/chio-go-http | sdks/cpp/chio-drogon |
| Integration shape | http.Handler wrapper | Drogon named middleware per route |
| Linking model | Pure Go; no cgo | Static link; add_subdirectory |
| Sidecar URL | chio.WithSidecarURL(...) option, else CHIO_SIDECAR_URL | chio::drogon::Options::sidecar_url |
| Receipt access | Response header only | chio::drogon::receipt_id(request) |
| Failure mode | Configurable on the option set | SidecarFailureMode::FailClosed in this example |
Next
- Go SDK reference
- HTTP Framework Middleware
- Protect an API: the zero-code reverse-proxy alternative.
- JVM and .NET, Node HTTP Frameworks, Python HTTP Frameworks
- One contract, every stack: the registration line for all six languages, side by side, plus the policy and the sidecar command they share.
- Examples Overview