go-http-route-method-compat-middleware
Root cause. The upstream opencode shim's route table only has GET/PATCH/DELETE /session/{id} and POST /session/{id}/message. A consensus client sends POST /session/{id} with a DexDat body {message:...} — the bare-session route has no POST handler, so the shim 404s. The fix is an in-repo compat middleware wrapping the mounted shim handler (upstream untouched).
The fix — new package sidecar/sessioncompat/sessioncompat.go (this is the complete middleware; the full verified file also lives in the repo at /tmp/compatdemo/sessioncompat/sessioncompat.go):
// Package sessioncompat bridges the consensus (DexDat) dialect of session
// messaging onto the upstream opencode shim's native route/body format.
package sessioncompat
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
)
type dexDatMessage struct {
Message string `json:"message"` // consensus request: {"message":"..."}
}
type opencodePart struct {
Type string `json:"type"`
Text string `json:"text"`
}
type opencodeSendMessage struct {
Parts []opencodePart `json:"parts"` // upstream POST /session/{id}/message body
}
// SessionPOSTCompat wraps the mounted shim handler. It intercepts
// POST /session/{id}, rewrites it to /session/{id}/message, translates the
// DexDat body to opencode parts format, and enriches the 200 JSON reply with
// a "response" field. Non-200 replies are replayed verbatim.
func SessionPOSTCompat(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && isBareSessionPath(r.URL.EscapedPath()) {
handlePostSession(w, r, next)
return
}
next.ServeHTTP(w, r) // everything else passes through byte-for-byte
})
}
// isBareSessionPath: exactly 2 non-empty segments, first == "session", no
// sub-endpoint, no trailing slash. Uses the escaped path so a %2F inside an
// id doesn't split segments.
func isBareSessionPath(p string) bool {
if strings.HasSuffix(p, "/") {
return false
}
segs := strings.Split(strings.TrimPrefix(p, "/"), "/")
return len(segs) == 2 && segs[0] == "session" && segs[1] != ""
}
func handlePostSession(w http.ResponseWriter, r *http.Request, next http.Handler) {
// 1) Rewrite /session/{id} -> /session/{id}/message.
if r.URL.RawPath != "" {
r.URL.RawPath += "/message"
}
r.URL.Path += "/message"
r.RequestURI = r.URL.RequestURI()
// chi detail: the mounted shim may itself be a chi router; Mount caches
// RoutePath at dispatch time, so the rewrite must be reflected there or
// the inner router keeps matching the bare route (404/405).
if rctx := chi.RouteContext(r.Context()); rctx != nil {
rctx.RoutePath = r.URL.Path
}
// 2) Translate DexDat {message} -> opencode {parts:[{type:text,...}]}.
translated, err := translateBody(r.Body)
if err != nil {
http.Error(w, "invalid message body: "+err.Error(), http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewReader(translated))
r.ContentLength = int64(len(translated))
// 3) Serve upstream through a capture writer so a 200 JSON reply can be
// enriched in place; non-200 / non-JSON replies replay verbatim.
cap := &capture{ResponseWriter: w, header: w.Header().Clone()}
next.ServeHTTP(cap, r)
cap.send()
}
func translateBody(src io.Reader) ([]byte, error) {
data, err := io.ReadAll(src)
if err != nil {
return nil, err
}
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return json.Marshal(opencodeSendMessage{Parts: []opencodePart{}})
}
var msg dexDatMessage
if err := json.Unmarshal(trimmed, &msg); err != nil {
return nil, err
}
out := opencodeSendMessage{Parts: []opencodePart{}}
if msg.Message != "" {
out.Parts = append(out.Parts, opencodePart{Type: "text", Text: msg.Message})
}
return json.Marshal(out)
}
// capture buffers the upstream response; headers/status are committed by send().
type capture struct {
http.ResponseWriter
header http.Header
status int
wroteHeader bool
body bytes.Buffer
}
func (c *capture) Header() http.Header {
if c.header == nil {
c.header = c.ResponseWriter.Header().Clone()
}
return c.header
}
func (c *capture) WriteHeader(code int) {
if !c.wroteHeader {
c.status, c.wroteHeader = code, true
}
}
func (c *capture) Write(p []byte) (int, error) {
if !c.wroteHeader {
c.WriteHeader(http.StatusOK)
}
return c.body.Write(p)
}
func (c *capture) Flush() {} // no early flush: the response commits in send()
func (c *capture) send() {
status := c.status
if status == 0 {
status = http.StatusOK
}
out := c.body.Bytes()
if status >= 200 && status < 300 && jsonish(c.Header().Get("Content-Type"), out) {
if enriched, ok := enrichJSON(out); ok {
out = enriched
c.Header().Set("Content-Length", strconv.Itoa(len(out)))
}
}
dh := c.ResponseWriter.Header()
for k, vs := range c.header {
for _, v := range vs {
dh.Add(k, v)
}
}
c.ResponseWriter.WriteHeader(status)
if len(out) > 0 {
_, _ = c.ResponseWriter.Write(out)
}
}
func jsonish(ct string, body []byte) bool {
if strings.Contains(strings.ToLower(ct), "json") {
return true
}
t := bytes.TrimSpace(body)
return len(t) > 0 && (t[0] == '{' || t[0] == '[')
}
// enrichJSON adds "response" (assistant text reply) to a JSON object lacking
// one; returns false -> verbatim replay.
func enrichJSON(body []byte) ([]byte, bool) {
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil || m == nil {
return nil, false
}
if _, ok := m["response"]; ok {
return nil, false
}
m["response"] = extractReply(m)
out, err := json.Marshal(m)
if err != nil {
return nil, false
}
return out, true
}
// extractReply handles opencode parts lists, flat text/output fields, and a
// pre-existing response field.
func extractReply(m map[string]any) string {
for _, k := range []string{"response", "text", "output"} {
if s, ok := m[k].(string); ok {
return s
}
}
if parts, ok := m["parts"].([]any); ok {
var sb strings.Builder
for _, p := range parts {
if pm, ok := p.(map[string]any); ok && pm["type"] == "text" {
if s, ok := pm["text"].(string); ok {
sb.WriteString(s)
}
}
}
return sb.String()
}
return ""
}
Wiring change in the sidecar router (the one-line repo edit):
r := chi.NewRouter()
// before: r.Mount("/", opencodeShimHandler)
// after: wrap the mounted shim handler with the compat middleware
r.Mount("/", sessioncompat.SessionPOSTCompat(opencodeShimHandler))
Two chi-specific traps the fix handles (both discovered by testing):
1. Wrap at Mount, not via r.Use on the outer router. chi's Mux.ServeHTTP reuses a parent RouteContext, and Mount re-derives rctx.RoutePath from the wildcard param captured at outer-match time — a pre-mount r.Use rewrite is silently undone. Wrapping the handler itself runs after the mount's path shift.
2. Also set rctx.RoutePath, not just r.URL.Path, because a mounted chi subrouter matches against rctx.RoutePath. Plain-http.Handler upstreams use r.URL.Path (also updated), so both kinds of shim work.
Verified with a real harness: Go 1.26 + `chi/v5 v5.2.1`, a mock upstream shim (chi) implementing exactly the opencode surface — `GET/PATCH/DELETE /session/{id}`, `POST /session/{id}/message`, nothing else. `go vet` clean; **11/11 tests pass, including with `-race`**.
Live end-to-end (`go run ./demo`):
```
== before fix: consensus POST /session/{id} ==
status=405 body= # shim rejects POST on bare route
== after fix: consensus POST /session/{id} ==
status=200 body={"echoed":[{"text":"hi","type":"text"}],"id":"s1",
"parts":[{"text":"hello from the model","type":"text"}],"response":"hello from the model"}
== non-POST / pass-through unaffected ==
GET /session/s1 -> 200 {"id":"s1","ok":true} # no response field added
PATCH /session/s1 -> 200 (no response field added)
== not bare /session/{id} ==
POST /session/s1/ -> 405 upstream verbatim (not rewritten)
POST /session/s1/message -> 200 direct, no double-rewrite, no enrichment
```
(The problem reports 404; chi's mock returns 405 Method Not Allowed — equivalent failure mode depending on the upstream's not-found handling. Either way the middleware restores 200.)
Edge cases tested:
| Case | Result |
|---|---|
| `POST /session/{id}` `{message:"hi"}` | 200; upstream saw `{"parts":[{"type":"text","text":"hi"}]}`; reply enriched with `"response"` |
| `POST /session/{id}?trace=t1` | query preserved; route still rewritten correctly |
| `GET/PATCH/DELETE /session/{id}` | pass through, byte-identical, never enriched |
| `POST /session/{id}/` (trailing slash) | not rewritten; upstream's own status/body verbatim |
| `POST /session/{id}/message` | not intercepted; native flow untouched |
| `POST /session` / deep path `/session/{id}/a/b` | pass through (404) |
| Upstream returns 404 on `/message` | replayed verbatim (`{"error":"send failed"}`, no `response` field) |
| Upstream 200 with `text/plain` body | replayed verbatim, no enrichment |
| Malformed JSON body | 400 fail-fast; upstream never reached with mangled body |
| `{message:""}` / empty body | rewritten with empty parts list; 200 |
| Already has `response` field / array / non-JSON 200 | verbatim replay |
| id containing `%2F` | still one segment (escaped-path detection) |
| Original 200 JSON fields (`id`, `parts`) | preserved; only `response` added |{"model": "deepseek-v4-flash", "problem_class": "go-http-route-method-compat-middleware", "result": "passed", "tests": 11}