Skip to content

Commit 9fa2b7f

Browse files
joe4devclaude
andcommitted
fix(ls-api): align mock with LocalStack API contract and add regression tests
- Return 202 Accepted from all handlers to match executor_endpoint.py - Add missing InvokedFunctionArn and TraceId fields to InvokeRequest - Parse {"logs":"..."} JSON in invokeLogsHandler instead of raw bytes - Read and log request body in invokeErrorHandler - Downgrade log.Fatal to log.Error in statusHandler goroutine - Add main_test.go with 7 regression tests covering all endpoints, JSON field names, and the async invoke triggered on status/ready Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e51e11d commit 9fa2b7f

2 files changed

Lines changed: 199 additions & 7 deletions

File tree

cmd/ls-api/main.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,26 @@ func main() {
6666
func invokeLogsHandler(w http.ResponseWriter, r *http.Request) {
6767
invokeId := chi.URLParam(r, "invoke_id")
6868
log.Println(invokeId)
69-
bodyBytes, err := io.ReadAll(r.Body)
70-
if err != nil {
71-
log.Error(err)
69+
var logResponse LogResponse
70+
if err := json.NewDecoder(r.Body).Decode(&logResponse); err != nil {
71+
log.Error("invalid logs payload: ", err)
72+
} else {
73+
log.Println("log result: " + logResponse.Logs)
7274
}
73-
log.Println("log result: " + string(bodyBytes))
75+
w.WriteHeader(http.StatusAccepted)
7476
}
7577

78+
// InvokeRequest is sent by LocalStack to trigger an invocation.
7679
type InvokeRequest struct {
77-
InvokeId string `json:"invoke-id"`
78-
Payload string `json:"payload"`
80+
InvokeId string `json:"invoke-id"`
81+
InvokedFunctionArn string `json:"invoked-function-arn"`
82+
Payload string `json:"payload"`
83+
TraceId string `json:"trace-id"`
84+
}
85+
86+
// LogResponse is sent by the runtime to report logs for a completed invocation.
87+
type LogResponse struct {
88+
Logs string `json:"logs"`
7989
}
8090

8191
func statusHandler(w http.ResponseWriter, r *http.Request) {
@@ -87,10 +97,11 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
8797
invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: "12345", Payload: "{\"counter\":0}"})
8898
_, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest))
8999
if err != nil {
90-
log.Fatal(err)
100+
log.Error(err)
91101
}
92102
}()
93103
}
104+
w.WriteHeader(http.StatusAccepted)
94105
}
95106

96107
func invokeResponseHandler(w http.ResponseWriter, r *http.Request) {
@@ -101,9 +112,16 @@ func invokeResponseHandler(w http.ResponseWriter, r *http.Request) {
101112
log.Error(err)
102113
}
103114
log.Println("result: " + string(bodyBytes))
115+
w.WriteHeader(http.StatusAccepted)
104116
}
105117

106118
func invokeErrorHandler(w http.ResponseWriter, r *http.Request) {
107119
invokeId := chi.URLParam(r, "invoke_id")
108120
log.Println(invokeId)
121+
bodyBytes, err := io.ReadAll(r.Body)
122+
if err != nil {
123+
log.Error(err)
124+
}
125+
log.Println("error result: " + string(bodyBytes))
126+
w.WriteHeader(http.StatusAccepted)
109127
}

cmd/ls-api/main_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/go-chi/chi"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
const testInvokeID = "test-invoke-id-12345"
17+
18+
// newTestRouter creates a chi router with the same LocalStack API routes as main(),
19+
// without the debug /test and /fail endpoints.
20+
func newTestRouter() *chi.Mux {
21+
r := chi.NewRouter()
22+
r.Post("/invocations/{invoke_id}/response", invokeResponseHandler)
23+
r.Post("/invocations/{invoke_id}/error", invokeErrorHandler)
24+
r.Post("/invocations/{invoke_id}/logs", invokeLogsHandler)
25+
r.Post("/status/{runtime_id}/{status}", statusHandler)
26+
return r
27+
}
28+
29+
// TestInvocationResponseReturns202 verifies POST /invocations/{id}/response returns 202 Accepted.
30+
// LocalStack's executor_endpoint.py invocation_response returns HTTPStatus.ACCEPTED.
31+
func TestInvocationResponseReturns202(t *testing.T) {
32+
srv := httptest.NewServer(newTestRouter())
33+
defer srv.Close()
34+
35+
resp, err := http.Post(
36+
srv.URL+"/invocations/"+testInvokeID+"/response",
37+
"application/json",
38+
bytes.NewBufferString(`{"result":"ok"}`),
39+
)
40+
require.NoError(t, err)
41+
defer resp.Body.Close()
42+
43+
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
44+
}
45+
46+
// TestInvocationErrorReturns202 verifies POST /invocations/{id}/error returns 202 Accepted.
47+
// LocalStack's executor_endpoint.py invocation_error returns HTTPStatus.ACCEPTED.
48+
func TestInvocationErrorReturns202(t *testing.T) {
49+
srv := httptest.NewServer(newTestRouter())
50+
defer srv.Close()
51+
52+
body := `{"errorMessage":"something went wrong","errorType":"RuntimeError","stackTrace":[]}`
53+
resp, err := http.Post(
54+
srv.URL+"/invocations/"+testInvokeID+"/error",
55+
"application/json",
56+
bytes.NewBufferString(body),
57+
)
58+
require.NoError(t, err)
59+
defer resp.Body.Close()
60+
61+
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
62+
}
63+
64+
// TestInvocationLogsReturns202 verifies POST /invocations/{id}/logs returns 202 Accepted
65+
// and accepts a {"logs":"..."} JSON body as sent by custom_interop.go via LogResponse.
66+
func TestInvocationLogsReturns202(t *testing.T) {
67+
srv := httptest.NewServer(newTestRouter())
68+
defer srv.Close()
69+
70+
logPayload, err := json.Marshal(LogResponse{
71+
Logs: "START RequestId: " + testInvokeID + " Version: $LATEST\nEND RequestId: " + testInvokeID + "\n",
72+
})
73+
require.NoError(t, err)
74+
75+
resp, err := http.Post(
76+
srv.URL+"/invocations/"+testInvokeID+"/logs",
77+
"application/json",
78+
bytes.NewReader(logPayload),
79+
)
80+
require.NoError(t, err)
81+
defer resp.Body.Close()
82+
83+
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
84+
}
85+
86+
// TestStatusReadyReturns202AndTriggersInvoke verifies that POST /status/{runtime_id}/ready:
87+
// - returns 202 Accepted (matching LocalStack executor_endpoint.py status_ready)
88+
// - asynchronously sends a POST to the invoke endpoint with a valid InvokeRequest body
89+
func TestStatusReadyReturns202AndTriggersInvoke(t *testing.T) {
90+
invokeCh := make(chan InvokeRequest, 1)
91+
captureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92+
var req InvokeRequest
93+
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
94+
invokeCh <- req
95+
w.WriteHeader(http.StatusOK)
96+
}))
97+
defer captureServer.Close()
98+
99+
origInvokeUrl := invokeUrl
100+
invokeUrl = captureServer.URL + "/invoke"
101+
defer func() { invokeUrl = origInvokeUrl }()
102+
103+
srv := httptest.NewServer(newTestRouter())
104+
defer srv.Close()
105+
106+
resp, err := http.Post(
107+
srv.URL+"/status/runtime-id-123/ready",
108+
"application/json",
109+
bytes.NewBufferString(""),
110+
)
111+
require.NoError(t, err)
112+
defer resp.Body.Close()
113+
114+
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
115+
116+
select {
117+
case req := <-invokeCh:
118+
assert.NotEmpty(t, req.InvokeId, "invoke-id must be set in the triggered InvokeRequest")
119+
assert.NotEmpty(t, req.Payload, "payload must be set in the triggered InvokeRequest")
120+
case <-time.After(2 * time.Second):
121+
t.Error("timed out waiting for invoke request to be sent after status/ready")
122+
}
123+
}
124+
125+
// TestStatusErrorReturns202 verifies POST /status/{runtime_id}/error returns 202 Accepted.
126+
// LocalStack's executor_endpoint.py status_error returns HTTPStatus.ACCEPTED on the first call.
127+
func TestStatusErrorReturns202(t *testing.T) {
128+
srv := httptest.NewServer(newTestRouter())
129+
defer srv.Close()
130+
131+
resp, err := http.Post(
132+
srv.URL+"/status/runtime-id-123/error",
133+
"application/json",
134+
bytes.NewBufferString(`{"errorMessage":"init failed","errorType":"InitError"}`),
135+
)
136+
require.NoError(t, err)
137+
defer resp.Body.Close()
138+
139+
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
140+
}
141+
142+
// TestInvokeRequestJSONFieldNames verifies that InvokeRequest uses the exact JSON field names
143+
// that LocalStack sends to the runtime's /invoke endpoint (as defined in custom_interop.go).
144+
//
145+
// WARNING: The LocalStack<->RIE API contract is currently unversioned. Any change to these
146+
// field names is a silent breaking change that requires a coordinated update of both
147+
// localstack-pro and lambda-runtime-init with no safe rollback path.
148+
func TestInvokeRequestJSONFieldNames(t *testing.T) {
149+
raw := `{
150+
"invoke-id": "abc-123",
151+
"invoked-function-arn": "arn:aws:lambda:us-east-1:000000000000:function:my-fn",
152+
"payload": "{\"key\":\"value\"}",
153+
"trace-id": "Root=1-abc;Parent=def;Sampled=1"
154+
}`
155+
156+
var req InvokeRequest
157+
require.NoError(t, json.Unmarshal([]byte(raw), &req))
158+
159+
assert.Equal(t, "abc-123", req.InvokeId)
160+
assert.Equal(t, "arn:aws:lambda:us-east-1:000000000000:function:my-fn", req.InvokedFunctionArn)
161+
assert.Equal(t, `{"key":"value"}`, req.Payload)
162+
assert.Equal(t, "Root=1-abc;Parent=def;Sampled=1", req.TraceId)
163+
}
164+
165+
// TestLogResponseJSONFieldName verifies that LogResponse uses the "logs" key
166+
// expected by LocalStack's executor_endpoint.py invocation_logs handler.
167+
func TestLogResponseJSONFieldName(t *testing.T) {
168+
raw := `{"logs":"START RequestId: abc\nEND RequestId: abc\n"}`
169+
170+
var lr LogResponse
171+
require.NoError(t, json.Unmarshal([]byte(raw), &lr))
172+
173+
assert.Equal(t, "START RequestId: abc\nEND RequestId: abc\n", lr.Logs)
174+
}

0 commit comments

Comments
 (0)