fix: use query params for workflow_id in Poimen endpoints
CI / CI (pull_request) Successful in 2m17s

Root cause: ServiceAdapter dispatcher sets X-Upstream-Path to template
strings like /v1/poimen/executions/{id}/stream with literal {id}.
extractWorkflowID() returned '{id}' instead of the actual workflow ID.

Fix:
- All handlers read workflow_id from ?workflow_id=xxx query param
- Remove path param extraction (extractWorkflowID)
- Update spec paths: remove {id} segments, use flat resource paths
  /v1/poimen/executions/detail?workflow_id=xxx
  /v1/poimen/executions/stream?workflow_id=xxx
  /v1/poimen/executions/history?workflow_id=xxx
  /v1/poimen/executions/signal?workflow_id=xxx
- Exact path matching in ServeHTTP (no prefix/suffix matching)

API contract:
  GET  /v1/poimen/executions                         — list all
  GET  /v1/poimen/executions/detail?workflow_id=xxx   — describe one
  GET  /v1/poimen/executions/history?workflow_id=xxx  — event history
  GET  /v1/poimen/executions/stream?workflow_id=xxx   — SSE events
  POST /v1/poimen/executions/signal?workflow_id=xxx   — signal
  DELETE /v1/poimen/executions/detail?workflow_id=xxx  — terminate
This commit is contained in:
Admin Bot
2026-09-26 20:46:27 +08:00
parent ea7bd8336b
commit 731e19cf26
2 changed files with 31 additions and 50 deletions
+8 -5
View File
@@ -53,17 +53,20 @@ func GetPoimenSpec() *Spec {
},
},
},
// Note: workflow_id is passed as ?workflow_id=xxx query param,
// not as a path segment. The ServiceAdapter dispatcher does not
// substitute path parameters.
{
Name: "execution-detail",
Methods: []Method{
{
Verb: "GET",
UpstreamPath: "/v1/poimen/executions/{id}",
UpstreamPath: "/v1/poimen/executions/detail",
Auth: &Auth{Required: true, Capability: "workflow:read"},
},
{
Verb: "DELETE",
UpstreamPath: "/v1/poimen/executions/{id}",
UpstreamPath: "/v1/poimen/executions/detail",
Auth: &Auth{Required: true, Capability: "workflow:execute"},
},
},
@@ -72,7 +75,7 @@ func GetPoimenSpec() *Spec {
Name: "execution-history",
Methods: []Method{{
Verb: "GET",
UpstreamPath: "/v1/poimen/executions/{id}/history",
UpstreamPath: "/v1/poimen/executions/history",
Auth: &Auth{Required: true, Capability: "workflow:read"},
}},
},
@@ -80,7 +83,7 @@ func GetPoimenSpec() *Spec {
Name: "execution-stream",
Methods: []Method{{
Verb: "GET",
UpstreamPath: "/v1/poimen/executions/{id}/stream",
UpstreamPath: "/v1/poimen/executions/stream",
Auth: &Auth{Required: true, Capability: "workflow:read"},
}},
},
@@ -88,7 +91,7 @@ func GetPoimenSpec() *Spec {
Name: "execution-signal",
Methods: []Method{{
Verb: "POST",
UpstreamPath: "/v1/poimen/executions/{id}/signal",
UpstreamPath: "/v1/poimen/executions/signal",
Auth: &Auth{Required: true, Capability: "workflow:execute"},
}},
},
+23 -45
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
@@ -33,26 +32,29 @@ func (p *PoimenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.handleExecute(w, r)
case path == "/v1/poimen/executions" && r.Method == http.MethodGet:
p.handleListExecutions(w, r)
case strings.HasPrefix(path, "/v1/poimen/executions/") && strings.HasSuffix(path, "/stream") && r.Method == http.MethodGet:
case path == "/v1/poimen/executions/stream" && r.Method == http.MethodGet:
p.handleStream(w, r)
case strings.HasPrefix(path, "/v1/poimen/executions/") && strings.HasSuffix(path, "/signal") && r.Method == http.MethodPost:
case path == "/v1/poimen/executions/signal" && r.Method == http.MethodPost:
p.handleSignal(w, r)
case strings.HasPrefix(path, "/v1/poimen/executions/") && strings.HasSuffix(path, "/history") && r.Method == http.MethodGet:
case path == "/v1/poimen/executions/history" && r.Method == http.MethodGet:
p.handleHistory(w, r)
case strings.HasPrefix(path, "/v1/poimen/executions/") && r.Method == http.MethodDelete:
case path == "/v1/poimen/executions/detail" && r.Method == http.MethodDelete:
p.handleTerminate(w, r)
case strings.HasPrefix(path, "/v1/poimen/executions/") && r.Method == http.MethodGet:
case path == "/v1/poimen/executions/detail" && r.Method == http.MethodGet:
p.handleDescribe(w, r)
default:
writeJSON(w, http.StatusNotFound, map[string]string{"error": "endpoint not found"})
}
}
// extractWorkflowID extracts the workflow ID from paths like /v1/poimen/executions/{id}/...
func extractWorkflowID(path string) string {
parts := strings.Split(strings.TrimPrefix(path, "/v1/poimen/executions/"), "/")
if len(parts) > 0 && parts[0] != "" {
return parts[0]
// getWorkflowID reads the workflow ID from query param (preferred) or path.
// The ServiceAdapter sets X-Upstream-Path to a template like
// /v1/poimen/executions/{id}/stream — the {id} is literal, not substituted.
// So workflow_id must come from a query parameter.
func getWorkflowID(r *http.Request) string {
// Query param is the primary source
if id := r.URL.Query().Get("workflow_id"); id != "" {
return id
}
return ""
}
@@ -176,13 +178,9 @@ func (p *PoimenHandler) handleDescribe(w http.ResponseWriter, r *http.Request) {
return
}
path := r.Header.Get("X-Upstream-Path")
if path == "" {
path = r.URL.Path
}
workflowID := extractWorkflowID(path)
workflowID := getWorkflowID(r)
if workflowID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
return
}
@@ -209,13 +207,9 @@ func (p *PoimenHandler) handleHistory(w http.ResponseWriter, r *http.Request) {
return
}
path := r.Header.Get("X-Upstream-Path")
if path == "" {
path = r.URL.Path
}
workflowID := extractWorkflowID(path)
workflowID := getWorkflowID(r)
if workflowID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
return
}
@@ -248,15 +242,9 @@ func (p *PoimenHandler) handleSignal(w http.ResponseWriter, r *http.Request) {
return
}
path := r.Header.Get("X-Upstream-Path")
if path == "" {
path = r.URL.Path
}
// Remove /signal suffix to get workflow ID
cleanPath := strings.TrimSuffix(path, "/signal")
workflowID := extractWorkflowID(cleanPath)
workflowID := getWorkflowID(r)
if workflowID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
return
}
@@ -290,13 +278,9 @@ func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request)
return
}
path := r.Header.Get("X-Upstream-Path")
if path == "" {
path = r.URL.Path
}
workflowID := extractWorkflowID(path)
workflowID := getWorkflowID(r)
if workflowID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
return
}
@@ -317,15 +301,9 @@ func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request)
// --- Stream (delegate to existing SSE handler) ---
func (p *PoimenHandler) handleStream(w http.ResponseWriter, r *http.Request) {
path := r.Header.Get("X-Upstream-Path")
if path == "" {
path = r.URL.Path
}
// Remove /stream suffix
cleanPath := strings.TrimSuffix(path, "/stream")
workflowID := extractWorkflowID(cleanPath)
workflowID := getWorkflowID(r)
if workflowID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
return
}