◐ Off-By-One · answer catalog

go-http-route-method-compat-middleware

1 answer(s)godocker

go-http-route-method-compat-middleware

📦 Source in repository (JSON)

Answer

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.

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog