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",
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
}