fix: use query params for workflow_id in Poimen endpoints (#42)
CI / CI (push) Successful in 3m7s

**Root cause:** ServiceAdapter dispatcher sets X-Upstream-Path to template strings like `/v1/poimen/executions/{id}/stream` with literal `{id}`. The handler extracted `{id}` as the workflow ID instead of the actual value.

**Fix:** All handlers read `workflow_id` from query params (`?workflow_id=xxx`). Paths changed to flat resource names without path parameters.

**API contract:**
```
GET  /executions                         — list
GET  /executions/detail?workflow_id=xxx   — describe
GET  /executions/history?workflow_id=xxx  — history
GET  /executions/stream?workflow_id=xxx   — SSE
POST /executions/signal?workflow_id=xxx   — signal
DELETE /executions/detail?workflow_id=xxx — terminate
```

All accessed via `X-Service: poimen` + `X-Resource: <name>` headers.

---------
Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-09-26 12:52:19 +00:00
parent ea7bd8336b
commit aaa9a5d1cb
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", Name: "execution-detail",
Methods: []Method{ Methods: []Method{
{ {
Verb: "GET", Verb: "GET",
UpstreamPath: "/v1/poimen/executions/{id}", UpstreamPath: "/v1/poimen/executions/detail",
Auth: &Auth{Required: true, Capability: "workflow:read"}, Auth: &Auth{Required: true, Capability: "workflow:read"},
}, },
{ {
Verb: "DELETE", Verb: "DELETE",
UpstreamPath: "/v1/poimen/executions/{id}", UpstreamPath: "/v1/poimen/executions/detail",
Auth: &Auth{Required: true, Capability: "workflow:execute"}, Auth: &Auth{Required: true, Capability: "workflow:execute"},
}, },
}, },
@@ -72,7 +75,7 @@ func GetPoimenSpec() *Spec {
Name: "execution-history", Name: "execution-history",
Methods: []Method{{ Methods: []Method{{
Verb: "GET", Verb: "GET",
UpstreamPath: "/v1/poimen/executions/{id}/history", UpstreamPath: "/v1/poimen/executions/history",
Auth: &Auth{Required: true, Capability: "workflow:read"}, Auth: &Auth{Required: true, Capability: "workflow:read"},
}}, }},
}, },
@@ -80,7 +83,7 @@ func GetPoimenSpec() *Spec {
Name: "execution-stream", Name: "execution-stream",
Methods: []Method{{ Methods: []Method{{
Verb: "GET", Verb: "GET",
UpstreamPath: "/v1/poimen/executions/{id}/stream", UpstreamPath: "/v1/poimen/executions/stream",
Auth: &Auth{Required: true, Capability: "workflow:read"}, Auth: &Auth{Required: true, Capability: "workflow:read"},
}}, }},
}, },
@@ -88,7 +91,7 @@ func GetPoimenSpec() *Spec {
Name: "execution-signal", Name: "execution-signal",
Methods: []Method{{ Methods: []Method{{
Verb: "POST", Verb: "POST",
UpstreamPath: "/v1/poimen/executions/{id}/signal", UpstreamPath: "/v1/poimen/executions/signal",
Auth: &Auth{Required: true, Capability: "workflow:execute"}, Auth: &Auth{Required: true, Capability: "workflow:execute"},
}}, }},
}, },
+23 -45
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
) )
@@ -33,26 +32,29 @@ func (p *PoimenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.handleExecute(w, r) p.handleExecute(w, r)
case path == "/v1/poimen/executions" && r.Method == http.MethodGet: case path == "/v1/poimen/executions" && r.Method == http.MethodGet:
p.handleListExecutions(w, r) 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) 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) 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) 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) 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) p.handleDescribe(w, r)
default: default:
writeJSON(w, http.StatusNotFound, map[string]string{"error": "endpoint not found"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "endpoint not found"})
} }
} }
// extractWorkflowID extracts the workflow ID from paths like /v1/poimen/executions/{id}/... // getWorkflowID reads the workflow ID from query param (preferred) or path.
func extractWorkflowID(path string) string { // The ServiceAdapter sets X-Upstream-Path to a template like
parts := strings.Split(strings.TrimPrefix(path, "/v1/poimen/executions/"), "/") // /v1/poimen/executions/{id}/stream — the {id} is literal, not substituted.
if len(parts) > 0 && parts[0] != "" { // So workflow_id must come from a query parameter.
return parts[0] func getWorkflowID(r *http.Request) string {
// Query param is the primary source
if id := r.URL.Query().Get("workflow_id"); id != "" {
return id
} }
return "" return ""
} }
@@ -176,13 +178,9 @@ func (p *PoimenHandler) handleDescribe(w http.ResponseWriter, r *http.Request) {
return return
} }
path := r.Header.Get("X-Upstream-Path") workflowID := getWorkflowID(r)
if path == "" {
path = r.URL.Path
}
workflowID := extractWorkflowID(path)
if workflowID == "" { 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 return
} }
@@ -209,13 +207,9 @@ func (p *PoimenHandler) handleHistory(w http.ResponseWriter, r *http.Request) {
return return
} }
path := r.Header.Get("X-Upstream-Path") workflowID := getWorkflowID(r)
if path == "" {
path = r.URL.Path
}
workflowID := extractWorkflowID(path)
if workflowID == "" { 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 return
} }
@@ -248,15 +242,9 @@ func (p *PoimenHandler) handleSignal(w http.ResponseWriter, r *http.Request) {
return return
} }
path := r.Header.Get("X-Upstream-Path") workflowID := getWorkflowID(r)
if path == "" {
path = r.URL.Path
}
// Remove /signal suffix to get workflow ID
cleanPath := strings.TrimSuffix(path, "/signal")
workflowID := extractWorkflowID(cleanPath)
if workflowID == "" { 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 return
} }
@@ -290,13 +278,9 @@ func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request)
return return
} }
path := r.Header.Get("X-Upstream-Path") workflowID := getWorkflowID(r)
if path == "" {
path = r.URL.Path
}
workflowID := extractWorkflowID(path)
if workflowID == "" { 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 return
} }
@@ -317,15 +301,9 @@ func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request)
// --- Stream (delegate to existing SSE handler) --- // --- Stream (delegate to existing SSE handler) ---
func (p *PoimenHandler) handleStream(w http.ResponseWriter, r *http.Request) { func (p *PoimenHandler) handleStream(w http.ResponseWriter, r *http.Request) {
path := r.Header.Get("X-Upstream-Path") workflowID := getWorkflowID(r)
if path == "" {
path = r.URL.Path
}
// Remove /stream suffix
cleanPath := strings.TrimSuffix(path, "/stream")
workflowID := extractWorkflowID(cleanPath)
if workflowID == "" { 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 return
} }