diff --git a/pkg/coop/blueprint.go b/pkg/coop/blueprint.go index d27f31ac..eef4e474 100644 --- a/pkg/coop/blueprint.go +++ b/pkg/coop/blueprint.go @@ -16,6 +16,7 @@ var blueprintFS embed.FS const ( blueprintNodeCandidatePrefix = "${node" blueprintNodeRefPrefix = "${node." + blueprintNodeAPIRequests = NodeType("apiRequests") ) // BlueprintStep is a step definition within a blueprint. @@ -77,17 +78,218 @@ func LoadBlueprint(id string) (*Blueprint, error) { } } - var bp Blueprint - if err := json.Unmarshal(data, &bp); err != nil { + bp, err := decodeBlueprint(data) + if err != nil { return nil, fmt.Errorf("parsing blueprint %q: %w", id, err) } - if err := validateBlueprintReferences(&bp); err != nil { + if err := prepareBlueprint(bp); err != nil { return nil, fmt.Errorf("validating blueprint %q: %w", id, err) } + return bp, nil +} + +// ParseBlueprint parses and validates a blueprint JSON document. +func ParseBlueprint(data []byte) (*Blueprint, error) { + bp, err := decodeBlueprint(data) + if err != nil { + return nil, fmt.Errorf("parsing blueprint: %w", err) + } + if err := prepareBlueprint(bp); err != nil { + return nil, fmt.Errorf("validating blueprint: %w", err) + } + return bp, nil +} + +func decodeBlueprint(data []byte) (*Blueprint, error) { + var bp Blueprint + if err := json.Unmarshal(data, &bp); err != nil { + return nil, err + } return &bp, nil } +func prepareBlueprint(bp *Blueprint) error { + if err := normalizeBlueprint(bp); err != nil { + return err + } + return validateBlueprint(bp) +} + +func normalizeBlueprint(bp *Blueprint) error { + requestRefs := make(map[string]string) + for stepIndex := range bp.Steps { + step := &bp.Steps[stepIndex] + for nodeIndex := range step.Nodes { + node := &step.Nodes[nodeIndex] + if node.Type != blueprintNodeAPIRequests { + continue + } + if node.Request != nil || len(node.TestRequests) != 1 { + return fmt.Errorf("step %q node %q: apiRequests nodes require exactly one request", step.Key, node.Key) + } + + requestDefinition := node.TestRequests[0] + request := requestDefinition.APIRequest + node.Type = NodeAPIRequest + node.Request = &request + node.TestRequests = nil + if step.Key != "" && node.Key != "" && requestDefinition.Key != "" { + requestRefs[step.Key+"."+node.Key+"."+requestDefinition.Key] = step.Key + "." + node.Key + } + } + } + return rewriteBlueprintReferences(bp, requestRefs) +} + +func rewriteBlueprintReferences(bp *Blueprint, refs map[string]string) error { + if len(refs) == 0 { + return nil + } + + data, err := json.Marshal(bp) + if err != nil { + return fmt.Errorf("marshaling blueprint for reference normalization: %w", err) + } + var document interface{} + if err := json.Unmarshal(data, &document); err != nil { + return fmt.Errorf("decoding blueprint for reference normalization: %w", err) + } + document, err = rewriteBlueprintReferenceValues(document, refs) + if err != nil { + return err + } + data, err = json.Marshal(document) + if err != nil { + return fmt.Errorf("marshaling normalized blueprint: %w", err) + } + var normalized Blueprint + if err := json.Unmarshal(data, &normalized); err != nil { + return fmt.Errorf("decoding normalized blueprint: %w", err) + } + *bp = normalized + return nil +} + +func rewriteBlueprintReferenceValues(value interface{}, refs map[string]string) (interface{}, error) { + switch typed := value.(type) { + case map[string]interface{}: + rewritten := make(map[string]interface{}, len(typed)) + for key, child := range typed { + normalizedKey := rewriteBlueprintReferenceString(key, refs) + if _, exists := rewritten[normalizedKey]; exists { + return nil, fmt.Errorf("reference normalization creates duplicate object key %q", normalizedKey) + } + normalizedChild, err := rewriteBlueprintReferenceValues(child, refs) + if err != nil { + return nil, err + } + rewritten[normalizedKey] = normalizedChild + } + return rewritten, nil + case []interface{}: + for i, child := range typed { + normalizedChild, err := rewriteBlueprintReferenceValues(child, refs) + if err != nil { + return nil, err + } + typed[i] = normalizedChild + } + return typed, nil + case string: + return rewriteBlueprintReferenceString(typed, refs), nil + default: + return value, nil + } +} + +func rewriteBlueprintReferenceString(value string, refs map[string]string) string { + for requestRef, nodeRef := range refs { + value = strings.ReplaceAll(value, "${node."+requestRef+":", "${node."+nodeRef+":") + } + return value +} + +func validateBlueprint(bp *Blueprint) error { + if strings.TrimSpace(bp.ID) == "" { + return fmt.Errorf("blueprint id is required") + } + if strings.TrimSpace(bp.Title) == "" { + return fmt.Errorf("blueprint title is required") + } + if len(bp.Steps) == 0 { + return fmt.Errorf("blueprint steps are required") + } + + stepKeys := make(map[string]bool, len(bp.Steps)) + for stepIndex, step := range bp.Steps { + if strings.TrimSpace(step.Key) == "" { + return fmt.Errorf("blueprint step %d key is required", stepIndex) + } + if stepKeys[step.Key] { + return fmt.Errorf("duplicate blueprint step key %q", step.Key) + } + stepKeys[step.Key] = true + if strings.TrimSpace(step.Title) == "" { + return fmt.Errorf("blueprint step %q title is required", step.Key) + } + if len(step.Nodes) == 0 { + return fmt.Errorf("blueprint step %q nodes are required", step.Key) + } + + nodeKeys := make(map[string]bool, len(step.Nodes)) + for nodeIndex, node := range step.Nodes { + if strings.TrimSpace(node.Key) == "" { + return fmt.Errorf("blueprint step %q node %d key is required", step.Key, nodeIndex) + } + if nodeKeys[node.Key] { + return fmt.Errorf("blueprint step %q has duplicate node key %q", step.Key, node.Key) + } + nodeKeys[node.Key] = true + if strings.TrimSpace(node.Title) == "" { + return fmt.Errorf("blueprint step %q node %q title is required", step.Key, node.Key) + } + if !supportedBlueprintNodeType(node.Type) { + return fmt.Errorf("blueprint step %q node %q has unsupported type %q for contract version %d", step.Key, node.Key, node.Type, CurrentBlueprintContractVersion) + } + if node.Type == NodeAPIRequest { + if node.Request == nil { + return fmt.Errorf("blueprint step %q apiRequest node %q request is required", step.Key, node.Key) + } + if strings.TrimSpace(node.Request.Path) == "" || strings.TrimSpace(node.Request.Method) == "" { + return fmt.Errorf("blueprint step %q apiRequest node %q request path and method are required", step.Key, node.Key) + } + } + if node.Type == NodeAsyncHandler && len(node.Events) == 0 { + return fmt.Errorf("blueprint step %q asyncHandler node %q events are required", step.Key, node.Key) + } + if node.ExpectedNumberOfEvents < 0 { + return fmt.Errorf("blueprint step %q node %q expectedNumberOfEvents must not be negative", step.Key, node.Key) + } + requestKeys := make(map[string]bool, len(node.TestRequests)) + for _, request := range node.TestRequests { + if strings.TrimSpace(request.Key) == "" || strings.TrimSpace(request.Path) == "" || strings.TrimSpace(request.Method) == "" { + return fmt.Errorf("blueprint step %q node %q requests require key, path, and method", step.Key, node.Key) + } + if requestKeys[request.Key] { + return fmt.Errorf("blueprint step %q node %q has duplicate request key %q", step.Key, node.Key, request.Key) + } + requestKeys[request.Key] = true + } + } + } + return validateBlueprintReferences(bp) +} + +func supportedBlueprintNodeType(nodeType NodeType) bool { + switch nodeType { + case NodeAPIRequest, NodeAsyncHandler, NodeUIComponent, NodeTestHelper, NodeCLICommand, NodeDashboard, NodeSetUpWebhooks: + return true + default: + return false + } +} + func validateBlueprintReferences(bp *Blueprint) error { validRefs := map[string]bool{} for _, step := range bp.Steps { diff --git a/pkg/coop/blueprint_test.go b/pkg/coop/blueprint_test.go index 92a4b877..9559d98b 100644 --- a/pkg/coop/blueprint_test.go +++ b/pkg/coop/blueprint_test.go @@ -20,6 +20,187 @@ func TestLoadBlueprint(t *testing.T) { assert.Equal(t, NodeAPIRequest, bp.Steps[0].Nodes[0].Type) } +func TestParseBlueprintCanonicalWireFields(t *testing.T) { + bp, err := ParseBlueprint([]byte(`{ + "id": "platform-payment", + "title": "Facilitate payments as a platform", + "settings": [], + "steps": [ + { + "key": "accounts", + "title": "Accounts", + "nodes": [ + { + "type": "apiRequests", + "key": "create-account", + "title": "Create account", + "requests": [ + { + "key": "create-account-request", + "path": "/v1/accounts", + "method": "post" + } + ] + } + ] + }, + { + "key": "payments", + "title": "Payments", + "nodes": [ + { + "type": "apiRequests", + "key": "create-checkout", + "title": "Create checkout", + "requests": [ + { + "key": "create-checkout-request", + "path": "/v1/checkout/sessions", + "method": "post", + "params": { + "metadata": { + "${node.accounts.create-account.create-account-request:id}": "seller" + } + }, + "requestOptions": { + "headers": { + "Stripe-Account": "${node.accounts.create-account.create-account-request:id}" + } + } + } + ] + }, + { + "type": "uiComponent", + "key": "complete-checkout", + "title": "Complete checkout", + "link": "${node.payments.create-checkout.create-checkout-request:url}" + }, + { + "type": "asyncHandler", + "key": "wait-for-checkout", + "title": "Wait for checkout", + "events": [ + { + "eventType": "checkout.session.completed", + "eventPayloadType": "snapshot" + } + ], + "expectedNumberOfEvents": 1 + } + ] + } + ] +}`)) + require.NoError(t, err) + + requestNode := bp.Steps[1].Nodes[0] + assert.Equal(t, NodeAPIRequest, requestNode.Type) + require.NotNil(t, requestNode.Request) + assert.Empty(t, requestNode.TestRequests) + request := requestNode.Request + headers, ok := request.RequestOptions["headers"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "${node.accounts.create-account:id}", headers["Stripe-Account"]) + params, ok := request.Params.(map[string]interface{}) + require.True(t, ok) + metadata, ok := params["metadata"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "seller", metadata["${node.accounts.create-account:id}"]) + assert.NotContains(t, metadata, "${node.accounts.create-account.create-account-request:id}") + assert.Equal(t, "${node.payments.create-checkout:url}", bp.Steps[1].Nodes[1].Link) + assert.Equal(t, []string{"checkout.session.completed"}, bp.Steps[1].Nodes[2].EventTypes()) + assert.Equal(t, "snapshot", bp.Steps[1].Nodes[2].Events[0].EventPayloadType) + assert.Equal(t, 1, bp.Steps[1].Nodes[2].ExpectedNumberOfEvents) + assert.Equal(t, 1, CurrentBlueprintContractVersion) +} + +func TestRewriteBlueprintReferenceValuesRejectsKeyCollisions(t *testing.T) { + value := map[string]interface{}{ + "${node.step.node.request:id}": "named request", + "${node.step.node:id}": "node", + } + + _, err := rewriteBlueprintReferenceValues(value, map[string]string{ + "step.node.request": "step.node", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate object key") +} + +func TestParseBlueprintRequiresCoreFields(t *testing.T) { + tests := []struct { + name string + raw string + wantError string + }{ + {name: "id", raw: `{"title":"Title","steps":[{"nodes":[]}]}`, wantError: "id is required"}, + {name: "title", raw: `{"id":"test","steps":[{"nodes":[]}]}`, wantError: "title is required"}, + {name: "steps", raw: `{"id":"test","title":"Title"}`, wantError: "steps are required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseBlueprint([]byte(tt.raw)) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestParseBlueprintValidatesRuntimeStructure(t *testing.T) { + tests := []struct { + name string + steps string + wantError string + }{ + {name: "blank step key", steps: `[{"key":"","title":"Step","nodes":[{"type":"uiComponent","key":"node","title":"Node"}]}]`, wantError: "step 0 key is required"}, + {name: "duplicate step key", steps: `[{"key":"step","title":"Step","nodes":[{"type":"uiComponent","key":"one","title":"One"}]},{"key":"step","title":"Step","nodes":[{"type":"uiComponent","key":"two","title":"Two"}]}]`, wantError: "duplicate blueprint step key"}, + {name: "empty nodes", steps: `[{"key":"step","title":"Step","nodes":[]}]`, wantError: "nodes are required"}, + {name: "duplicate node key", steps: `[{"key":"step","title":"Step","nodes":[{"type":"uiComponent","key":"node","title":"One"},{"type":"uiComponent","key":"node","title":"Two"}]}]`, wantError: "duplicate node key"}, + {name: "unsupported node type", steps: `[{"key":"step","title":"Step","nodes":[{"type":"futureNode","key":"node","title":"Node"}]}]`, wantError: "unsupported type"}, + {name: "missing API request", steps: `[{"key":"step","title":"Step","nodes":[{"type":"apiRequest","key":"node","title":"Node"}]}]`, wantError: "request is required"}, + {name: "missing async events", steps: `[{"key":"step","title":"Step","nodes":[{"type":"asyncHandler","key":"node","title":"Node"}]}]`, wantError: "events are required"}, + {name: "plural API request list empty", steps: `[{"key":"step","title":"Step","nodes":[{"type":"apiRequests","key":"node","title":"Node","requests":[]}]}]`, wantError: "require exactly one request"}, + {name: "plural API request list ambiguous", steps: `[{"key":"step","title":"Step","nodes":[{"type":"apiRequests","key":"node","title":"Node","requests":[{"key":"one","path":"/v1/one","method":"get"},{"key":"two","path":"/v1/two","method":"get"}]}]}]`, wantError: "require exactly one request"}, + {name: "duplicate request key", steps: `[{"key":"step","title":"Step","nodes":[{"type":"testHelper","key":"node","title":"Node","requests":[{"key":"request","path":"/v1/one","method":"get"},{"key":"request","path":"/v1/two","method":"get"}]}]}]`, wantError: "duplicate request key"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := `{"id":"test","title":"Title","steps":` + tt.steps + `}` + _, err := ParseBlueprint([]byte(raw)) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestParseBlueprintValidatesReferencesInCanonicalWireFields(t *testing.T) { + tests := []struct { + name string + node string + }{ + { + name: "request options", + node: `{"type":"apiRequests","key":"request","title":"Request","requests":[{"key":"request","path":"/v1/test","method":"post","requestOptions":{"headers":{"Stripe-Account":"${node.missing.node:id}"}}}]}`, + }, + { + name: "link", + node: `{"type":"uiComponent","key":"link","title":"Link","link":"${node.missing.node:url}"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := `{"id":"test","title":"Title","steps":[{"key":"step","title":"Step","nodes":[` + tt.node + `]}]}` + _, err := ParseBlueprint([]byte(raw)) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown node reference") + }) + } +} + func TestAllEmbeddedBlueprintsHaveQualityMetadata(t *testing.T) { ids, err := ListBlueprints() require.NoError(t, err) @@ -329,7 +510,7 @@ func TestNewSessionFromBlueprintPreservesEvents(t *testing.T) { for _, ch := range session.Steps { for _, n := range ch.Nodes { if n.Type == NodeAsyncHandler { - assert.Contains(t, n.Events, "checkout.session.completed") + assert.Contains(t, n.EventTypes(), "checkout.session.completed") return } } @@ -337,6 +518,50 @@ func TestNewSessionFromBlueprintPreservesEvents(t *testing.T) { t.Fatal("expected to find asyncHandler node") } +func TestNewSessionFromBlueprintPreservesCanonicalWireFields(t *testing.T) { + bp := &Blueprint{ + ID: "canonical-fields", + Title: "Canonical fields", + Steps: []BlueprintStep{{ + StepDefinition: StepDefinition{Key: "step", Title: "Step"}, + Nodes: []NodeDefinition{ + { + Type: NodeAPIRequest, + Key: "request", + Title: "Request", + Request: &APIRequest{ + Path: "/v1/test", + Method: "post", + RequestOptions: map[string]interface{}{"idempotency_key": "test-key"}, + }, + }, + { + Type: NodeAsyncHandler, + Key: "event", + Title: "Event", + Events: []EventDefinition{{EventType: "invoice.paid", EventPayloadType: "snapshot"}}, + ExpectedNumberOfEvents: 1, + }, + { + Type: NodeUIComponent, + Key: "link", + Title: "Link", + Link: "https://example.com/checkout", + }, + }, + }}, + } + + session := NewSessionFromBlueprint(bp, "test_123", nil, nil) + requestNode := session.Steps[1].Nodes[0] + assert.Equal(t, "test-key", requestNode.Request.RequestOptions["idempotency_key"]) + eventNode := session.Steps[1].Nodes[1] + assert.Equal(t, []string{"invoice.paid"}, eventNode.EventTypes()) + assert.Equal(t, "snapshot", eventNode.Events[0].EventPayloadType) + assert.Equal(t, 1, eventNode.ExpectedNumberOfEvents) + assert.Equal(t, "https://example.com/checkout", session.Steps[1].Nodes[2].Link) +} + func TestEmbeddedBlueprintsUseCanonicalJSON(t *testing.T) { ids, err := ListBlueprints() require.NoError(t, err) diff --git a/pkg/coop/event.go b/pkg/coop/event.go new file mode 100644 index 00000000..91085d74 --- /dev/null +++ b/pkg/coop/event.go @@ -0,0 +1,91 @@ +package coop + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +// EventDefinition describes a webhook or async event required by a blueprint. +// Blueprint JSON may use either the legacy event-type string or this object form. +type EventDefinition struct { + EventType string `json:"eventType"` + EventPayloadType string `json:"eventPayloadType,omitempty"` + legacyString bool +} + +// UnmarshalJSON accepts legacy string events and structured event definitions. +func (e *EventDefinition) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 { + return fmt.Errorf("event definition must be a string or object") + } + + var parsed EventDefinition + switch trimmed[0] { + case '"': + if err := json.Unmarshal(trimmed, &parsed.EventType); err != nil { + return fmt.Errorf("parsing event type: %w", err) + } + parsed.legacyString = true + case '{': + var fields map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &fields); err != nil { + return fmt.Errorf("parsing event definition: %w", err) + } + for field := range fields { + if field != "eventType" && field != "eventPayloadType" { + return fmt.Errorf("event definition contains unsupported field %q", field) + } + } + eventType, ok := fields["eventType"] + if !ok || bytes.Equal(bytes.TrimSpace(eventType), []byte("null")) { + return fmt.Errorf("event definition eventType is required") + } + if err := json.Unmarshal(eventType, &parsed.EventType); err != nil { + return fmt.Errorf("parsing event definition eventType: %w", err) + } + if payloadType, ok := fields["eventPayloadType"]; ok { + if bytes.Equal(bytes.TrimSpace(payloadType), []byte("null")) { + return fmt.Errorf("event definition eventPayloadType must be a string") + } + if err := json.Unmarshal(payloadType, &parsed.EventPayloadType); err != nil { + return fmt.Errorf("parsing event definition eventPayloadType: %w", err) + } + } + default: + return fmt.Errorf("event definition must be a string or object") + } + + if strings.TrimSpace(parsed.EventType) == "" { + return fmt.Errorf("event definition eventType is required") + } + if parsed.EventType != strings.TrimSpace(parsed.EventType) { + return fmt.Errorf("event definition eventType must not have surrounding whitespace") + } + if parsed.EventPayloadType != strings.TrimSpace(parsed.EventPayloadType) { + return fmt.Errorf("event definition eventPayloadType must not have surrounding whitespace") + } + *e = parsed + return nil +} + +// MarshalJSON preserves the legacy string form when that was the input form. +func (e EventDefinition) MarshalJSON() ([]byte, error) { + if strings.TrimSpace(e.EventType) == "" { + return nil, fmt.Errorf("event definition eventType is required") + } + if e.EventType != strings.TrimSpace(e.EventType) { + return nil, fmt.Errorf("event definition eventType must not have surrounding whitespace") + } + if e.EventPayloadType != strings.TrimSpace(e.EventPayloadType) { + return nil, fmt.Errorf("event definition eventPayloadType must not have surrounding whitespace") + } + if e.legacyString && e.EventPayloadType == "" { + return json.Marshal(e.EventType) + } + + type eventDefinition EventDefinition + return json.Marshal(eventDefinition(e)) +} diff --git a/pkg/coop/event_test.go b/pkg/coop/event_test.go new file mode 100644 index 00000000..da9d32db --- /dev/null +++ b/pkg/coop/event_test.go @@ -0,0 +1,69 @@ +package coop + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventDefinitionLegacyStringRoundTrip(t *testing.T) { + var event EventDefinition + require.NoError(t, json.Unmarshal([]byte(`"checkout.session.completed"`), &event)) + assert.Equal(t, "checkout.session.completed", event.EventType) + assert.Empty(t, event.EventPayloadType) + + encoded, err := json.Marshal(event) + require.NoError(t, err) + assert.Equal(t, `"checkout.session.completed"`, string(encoded)) +} + +func TestEventDefinitionStructuredRoundTrip(t *testing.T) { + raw := `{"eventType":"invoice.paid","eventPayloadType":"snapshot"}` + + var event EventDefinition + require.NoError(t, json.Unmarshal([]byte(raw), &event)) + assert.Equal(t, "invoice.paid", event.EventType) + assert.Equal(t, "snapshot", event.EventPayloadType) + + encoded, err := json.Marshal(event) + require.NoError(t, err) + assert.JSONEq(t, raw, string(encoded)) +} + +func TestEventDefinitionRejectsInvalidJSONShapes(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "null", raw: `null`}, + {name: "number", raw: `42`}, + {name: "boolean", raw: `true`}, + {name: "array", raw: `[]`}, + {name: "missing event type", raw: `{}`}, + {name: "empty object event type", raw: `{"eventType":""}`}, + {name: "blank object event type", raw: `{"eventType":" "}`}, + {name: "padded object event type", raw: `{"eventType":" invoice.paid "}`}, + {name: "null payload type", raw: `{"eventType":"invoice.paid","eventPayloadType":null}`}, + {name: "padded payload type", raw: `{"eventType":"invoice.paid","eventPayloadType":" snapshot "}`}, + {name: "unknown field", raw: `{"eventType":"invoice.paid","futureField":true}`}, + {name: "empty string event type", raw: `""`}, + {name: "blank string event type", raw: `" "`}, + {name: "padded string event type", raw: `" invoice.paid "`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var event EventDefinition + err := json.Unmarshal([]byte(tt.raw), &event) + require.Error(t, err) + }) + } +} + +func TestEventDefinitionProgrammaticValueUsesObjectForm(t *testing.T) { + encoded, err := json.Marshal(EventDefinition{EventType: "invoice.paid"}) + require.NoError(t, err) + assert.JSONEq(t, `{"eventType":"invoice.paid"}`, string(encoded)) +} diff --git a/pkg/coop/session_json.go b/pkg/coop/session_json.go new file mode 100644 index 00000000..9b1a54fe --- /dev/null +++ b/pkg/coop/session_json.go @@ -0,0 +1,103 @@ +package coop + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +type sessionEventDefinition struct { + EventType string `json:"eventType"` + EventPayloadType string `json:"eventPayloadType,omitempty"` +} + +func (e *sessionEventDefinition) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || trimmed[0] != '{' { + return fmt.Errorf("session event definition must be an object") + } + + var event EventDefinition + if err := json.Unmarshal(trimmed, &event); err != nil { + return err + } + e.EventType = event.EventType + e.EventPayloadType = event.EventPayloadType + return nil +} + +// MarshalJSON keeps the schema-v2 events field readable by older CLI versions. +// Structured event metadata is stored separately in an additive field. Older +// clients preserve event names but may drop that metadata when rewriting a file. +func (n SessionNode) MarshalJSON() ([]byte, error) { + type sessionNodeAlias SessionNode + + legacyNode := n + legacyNode.Events = make([]EventDefinition, len(n.Events)) + var eventDefinitions []sessionEventDefinition + for i, event := range n.Events { + legacyNode.Events[i] = event + legacyNode.Events[i].legacyString = true + legacyNode.Events[i].EventPayloadType = "" + if !event.legacyString || event.EventPayloadType != "" { + eventDefinitions = make([]sessionEventDefinition, len(n.Events)) + } + } + if eventDefinitions != nil { + for i, event := range n.Events { + eventDefinitions[i] = sessionEventDefinition{ + EventType: event.EventType, + EventPayloadType: event.EventPayloadType, + } + } + } + + return json.Marshal(struct { + sessionNodeAlias + EventDefinitions []sessionEventDefinition `json:"event_definitions,omitempty"` + }{ + sessionNodeAlias: sessionNodeAlias(legacyNode), + EventDefinitions: eventDefinitions, + }) +} + +// UnmarshalJSON restores structured metadata while accepting legacy sessions. +func (n *SessionNode) UnmarshalJSON(data []byte) error { + type sessionNodeAlias SessionNode + var stored struct { + sessionNodeAlias + EventDefinitions json.RawMessage `json:"event_definitions"` + } + if err := json.Unmarshal(data, &stored); err != nil { + return err + } + + if len(stored.EventDefinitions) > 0 { + if bytes.Equal(bytes.TrimSpace(stored.EventDefinitions), []byte("null")) { + return fmt.Errorf("event_definitions must be an array") + } + var definitions []sessionEventDefinition + if err := json.Unmarshal(stored.EventDefinitions, &definitions); err != nil { + return fmt.Errorf("parsing event_definitions: %w", err) + } + if len(definitions) != len(stored.Events) { + return fmt.Errorf("event_definitions length %d does not match events length %d", len(definitions), len(stored.Events)) + } + for i, definition := range definitions { + if strings.TrimSpace(definition.EventType) == "" { + return fmt.Errorf("event_definitions[%d].eventType is required", i) + } + if definition.EventType != stored.Events[i].EventType { + return fmt.Errorf("event_definitions[%d].eventType %q does not match events[%d] %q", i, definition.EventType, i, stored.Events[i].EventType) + } + stored.Events[i] = EventDefinition{ + EventType: definition.EventType, + EventPayloadType: definition.EventPayloadType, + } + } + } + + *n = SessionNode(stored.sessionNodeAlias) + return nil +} diff --git a/pkg/coop/session_json_test.go b/pkg/coop/session_json_test.go new file mode 100644 index 00000000..85145868 --- /dev/null +++ b/pkg/coop/session_json_test.go @@ -0,0 +1,144 @@ +package coop + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionStructuredEventsPreserveSchemaV2Compatibility(t *testing.T) { + dir := t.TempDir() + store, err := NewStoreAt(dir) + require.NoError(t, err) + + session := &Session{ + SchemaVersion: CurrentSessionSchemaVersion, + ID: "structured_events", + Blueprint: "test", + Status: SessionActive, + Steps: []SessionStep{{ + StepDefinition: StepDefinition{Key: "step", Title: "Step"}, + Nodes: []SessionNode{ + { + NodeDefinition: NodeDefinition{ + Type: NodeAsyncHandler, + Key: "event", + Title: "Event", + Events: []EventDefinition{{EventType: "invoice.paid", EventPayloadType: "snapshot"}}, + ExpectedNumberOfEvents: 1, + }, + State: NodePending, + }, + { + NodeDefinition: NodeDefinition{ + Type: NodeAPIRequest, + Key: "request", + Title: "Request", + Request: &APIRequest{ + Path: "/v1/test", + Method: "post", + RequestOptions: map[string]interface{}{"idempotency_key": "test-key"}, + }, + Link: "https://example.com/checkout", + }, + State: NodePending, + }, + }, + }}, + } + require.NoError(t, store.Write(session)) + + raw, err := os.ReadFile(filepath.Join(dir, "structured_events.json")) + require.NoError(t, err) + assert.Contains(t, string(raw), `"schema_version": 2`) + assert.Contains(t, string(raw), `"event_definitions"`) + + var legacy struct { + SchemaVersion int `json:"schema_version"` + Steps []struct { + Nodes []struct { + Events []string `json:"events"` + } `json:"nodes"` + } `json:"steps"` + } + require.NoError(t, json.Unmarshal(raw, &legacy)) + assert.Equal(t, CurrentSessionSchemaVersion, legacy.SchemaVersion) + assert.Equal(t, []string{"invoice.paid"}, legacy.Steps[0].Nodes[0].Events) + + loaded, err := store.Read("structured_events") + require.NoError(t, err) + event := loaded.Steps[0].Nodes[0].Events[0] + assert.Equal(t, "invoice.paid", event.EventType) + assert.Equal(t, "snapshot", event.EventPayloadType) + assert.Equal(t, 1, loaded.Steps[0].Nodes[0].ExpectedNumberOfEvents) + assert.Equal(t, "test-key", loaded.Steps[0].Nodes[1].Request.RequestOptions["idempotency_key"]) + assert.Equal(t, "https://example.com/checkout", loaded.Steps[0].Nodes[1].Link) + + _, err = store.Update("structured_events", func(session *Session) error { + session.Steps[0].Nodes[0].Activity = "still working" + return nil + }) + require.NoError(t, err) + loaded, err = store.Read("structured_events") + require.NoError(t, err) + assert.Equal(t, "snapshot", loaded.Steps[0].Nodes[0].Events[0].EventPayloadType) +} + +func TestSessionNodeReadsLegacyStringEvents(t *testing.T) { + var node SessionNode + require.NoError(t, json.Unmarshal([]byte(`{"events":["checkout.session.completed"],"state":"pending"}`), &node)) + assert.Equal(t, []string{"checkout.session.completed"}, node.EventTypes()) + assert.Empty(t, node.Events[0].EventPayloadType) + + encoded, err := json.Marshal(node) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "event_definitions") +} + +func TestSessionNodeMigratesObjectValuedEvents(t *testing.T) { + var node SessionNode + require.NoError(t, json.Unmarshal([]byte(`{"events":[{"eventType":"invoice.paid","eventPayloadType":"snapshot"}],"state":"pending"}`), &node)) + assert.Equal(t, []string{"invoice.paid"}, node.EventTypes()) + assert.Equal(t, "snapshot", node.Events[0].EventPayloadType) + + encoded, err := json.Marshal(node) + require.NoError(t, err) + var legacy struct { + Events []string `json:"events"` + } + require.NoError(t, json.Unmarshal(encoded, &legacy)) + assert.Equal(t, []string{"invoice.paid"}, legacy.Events) + assert.Contains(t, string(encoded), "event_definitions") +} + +func TestSessionNodeRejectsInconsistentEventDefinitions(t *testing.T) { + tests := []struct { + name string + raw string + }{ + { + name: "length", + raw: `{"events":["invoice.paid"],"event_definitions":[]}`, + }, + { + name: "order", + raw: `{"events":["invoice.paid"],"event_definitions":[{"eventType":"checkout.session.completed","eventPayloadType":"snapshot"}]}`, + }, + { + name: "null", + raw: `{"events":[],"event_definitions":null}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var node SessionNode + err := json.Unmarshal([]byte(tt.raw), &node) + require.Error(t, err) + }) + } +} diff --git a/pkg/coop/tui/detail.go b/pkg/coop/tui/detail.go index 7a0e0dc6..10b5c0d7 100644 --- a/pkg/coop/tui/detail.go +++ b/pkg/coop/tui/detail.go @@ -268,8 +268,9 @@ func (m Model) writeStepChecksDetail(md *strings.Builder, ch *coop.SessionStep) func (m Model) writeStepReferenceDetail(md *strings.Builder, ch *coop.SessionStep) { wrote := false for _, node := range ch.Nodes { - if node.Type == coop.NodeAsyncHandler && len(node.Events) > 0 { - md.WriteString("- `" + node.Events[0] + "` webhook trigger for " + node.Title + "\n") + events := node.EventTypes() + if node.Type == coop.NodeAsyncHandler && len(events) > 0 { + md.WriteString("- `" + events[0] + "` webhook trigger for " + node.Title + "\n") wrote = true } if node.Type == coop.NodeAPIRequest && node.Request != nil { @@ -339,21 +340,23 @@ func stepConfirmationNodes(ch *coop.SessionStep) string { } func (m Model) writeAsyncHandlerCheckDetail(md *strings.Builder, node *coop.SessionNode) { - if node.Type != coop.NodeAsyncHandler || len(node.Events) == 0 { + events := node.EventTypes() + if node.Type != coop.NodeAsyncHandler || len(events) == 0 { return } md.WriteString("**How to verify:**\n\n") md.WriteString("1. `stripe listen --forward-to localhost:/webhook`\n") - md.WriteString("2. `stripe trigger " + node.Events[0] + "`\n") + md.WriteString("2. `stripe trigger " + events[0] + "`\n") md.WriteString("3. Confirm your handler processes the event\n\n") } func (m Model) writeAsyncHandlerReferenceDetail(md *strings.Builder, node *coop.SessionNode) { - if node.Type != coop.NodeAsyncHandler || len(node.Events) == 0 { + events := node.EventTypes() + if node.Type != coop.NodeAsyncHandler || len(events) == 0 { return } md.WriteString("**Webhook trigger:**\n\n") - md.WriteString("`stripe trigger " + node.Events[0] + "`\n\n") + md.WriteString("`stripe trigger " + events[0] + "`\n\n") } func (m Model) writeReviewCommandDetail(md *strings.Builder, node *coop.SessionNode) { diff --git a/pkg/coop/tui/review.go b/pkg/coop/tui/review.go index a66ceca3..042a1285 100644 --- a/pkg/coop/tui/review.go +++ b/pkg/coop/tui/review.go @@ -356,8 +356,9 @@ func reviewCommandForNode(node *coop.SessionNode) string { if node.ReviewCommand != "" { return node.ReviewCommand } - if node.Type == coop.NodeAsyncHandler && len(node.Events) > 0 { - return "stripe trigger " + node.Events[0] + events := node.EventTypes() + if node.Type == coop.NodeAsyncHandler && len(events) > 0 { + return "stripe trigger " + events[0] } return "" } diff --git a/pkg/coop/tui/view_test.go b/pkg/coop/tui/view_test.go index e4a5f0d9..ad09f709 100644 --- a/pkg/coop/tui/view_test.go +++ b/pkg/coop/tui/view_test.go @@ -78,7 +78,7 @@ func testModel() Model { Key: "n3", Title: "Handle event", Type: coop.NodeAsyncHandler, - Events: []string{"checkout.session.completed"}, + Events: []coop.EventDefinition{{EventType: "checkout.session.completed"}}, }, State: coop.NodePending, }, diff --git a/pkg/coop/types.go b/pkg/coop/types.go index baf4ab30..13b77563 100644 --- a/pkg/coop/types.go +++ b/pkg/coop/types.go @@ -8,7 +8,9 @@ import "time" type NodeState string const ( - CurrentSessionSchemaVersion = 2 + // CurrentBlueprintContractVersion is the supported external blueprint JSON contract. + CurrentBlueprintContractVersion = 1 + CurrentSessionSchemaVersion = 2 ) const ( @@ -57,11 +59,12 @@ type Verification struct { // APIRequest describes the expected API call for a node. type APIRequest struct { - Path string `json:"path"` - Method string `json:"method"` - Headers map[string]string `json:"headers,omitempty"` - Params interface{} `json:"params,omitempty"` - HiddenParams interface{} `json:"hidden_params,omitempty"` + Path string `json:"path"` + Method string `json:"method"` + Headers map[string]string `json:"headers,omitempty"` + Params interface{} `json:"params,omitempty"` + HiddenParams interface{} `json:"hidden_params,omitempty"` + RequestOptions map[string]interface{} `json:"requestOptions,omitempty"` } // TestHelperRequest describes an API-backed request used to advance test state. @@ -72,16 +75,33 @@ type TestHelperRequest struct { // NodeDefinition is the source-derived static definition for a node. type NodeDefinition struct { - Type NodeType `json:"type"` - Key string `json:"key"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - ReviewPrompt string `json:"review_prompt,omitempty"` - ReviewCommand string `json:"review_command,omitempty"` - AutoConfirm bool `json:"auto_confirm,omitempty"` - Request *APIRequest `json:"request,omitempty"` - TestRequests []TestHelperRequest `json:"requests,omitempty"` - Events []string `json:"events,omitempty"` + Type NodeType `json:"type"` + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + ReviewPrompt string `json:"review_prompt,omitempty"` + ReviewCommand string `json:"review_command,omitempty"` + AutoConfirm bool `json:"auto_confirm,omitempty"` + Request *APIRequest `json:"request,omitempty"` + TestRequests []TestHelperRequest `json:"requests,omitempty"` + Events []EventDefinition `json:"events,omitempty"` + ExpectedNumberOfEvents int `json:"expectedNumberOfEvents,omitempty"` + Link string `json:"link,omitempty"` +} + +// EventTypes returns the event type names required by this node. +func (n NodeDefinition) EventTypes() []string { + if len(n.Events) == 0 { + return nil + } + + eventTypes := make([]string, 0, len(n.Events)) + for _, event := range n.Events { + if event.EventType != "" { + eventTypes = append(eventTypes, event.EventType) + } + } + return eventTypes } // StepDefinition is the source-derived static definition for a step.