Skip to content
10 changes: 8 additions & 2 deletions .github/workflows/deploy-apigw.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,14 @@ jobs:
exit 1
fi
ARN=$(aws lambda get-function --function-name "$FN_NAME" --query 'Configuration.FunctionArn' --output text)
echo "Lambda ARN: $ARN"
echo "arn=$ARN" >> "$GITHUB_OUTPUT"
# If ARN is qualified with version/alias (e.g., :function:name:10), strip the qualifier
if [[ "$ARN" =~ ^(arn:[^:]+:lambda:[^:]+:[0-9]+:function:[^:]+):[^:]+$ ]]; then
BASE_ARN="${BASH_REMATCH[1]}"
else
BASE_ARN="$ARN"
fi
echo "Lambda ARN (base): $BASE_ARN"
echo "arn=$BASE_ARN" >> "$GITHUB_OUTPUT"

- name: Cleanup previous failed stack (if any)
env:
Expand Down
50 changes: 44 additions & 6 deletions .github/workflows/deploy-demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ on:
function_name:
description: "Demo Lambda function name"
required: true
default: "random-demo"
default: "random"
api_name:
description: "API Gateway name"
required: false
default: "random-demo-api"
default: "random-api"
stage_name:
description: "Stage name"
required: false
default: "prod"
stack_name:
description: "CloudFormation stack name"
required: false
default: "random-demo-apigw"
default: "random-apigw"

jobs:
deploy-demo:
Expand Down Expand Up @@ -71,6 +71,34 @@ jobs:
publish: true
role: ${{ secrets.LAMBDA_EXECUTION_ROLE }}

- name: Cleanup previous failed demo stack (if any)
env:
STACK_NAME: ${{ inputs.stack_name }}
run: |
set -euo pipefail
STACK="$STACK_NAME"
if aws cloudformation describe-stacks --stack-name "$STACK" >/dev/null 2>&1; then
STATUS=$(aws cloudformation describe-stacks --stack-name "$STACK" --query 'Stacks[0].StackStatus' --output text)
echo "Current demo stack status: $STATUS"
case "$STATUS" in
ROLLBACK_FAILED|ROLLBACK_COMPLETE|UPDATE_ROLLBACK_FAILED|UPDATE_ROLLBACK_COMPLETE|CREATE_FAILED|DELETE_FAILED)
echo "Deleting stuck stack $STACK..."
aws cloudformation delete-stack --stack-name "$STACK"
echo "Waiting for deletion to complete..."
aws cloudformation wait stack-delete-complete --stack-name "$STACK" || {
echo "Waiter failed; showing recent events:";
aws cloudformation describe-stack-events --stack-name "$STACK" --output text | head -n 200 || true;
echo "Retrying deletion...";
aws cloudformation delete-stack --stack-name "$STACK" || true;
aws cloudformation wait stack-delete-complete --stack-name "$STACK";
}
;;
*) echo "Stack exists in status $STATUS; proceeding." ;;
esac
else
echo "Demo stack does not exist; proceeding."
fi

- name: Deploy REST API (demo)
id: demo-cfn
env:
Expand All @@ -80,14 +108,22 @@ jobs:
run: |
set -euo pipefail
STACK="${STACK_NAME}"
# Resolve the unqualified (base) function ARN to avoid per-version permissions
FN_ARN=$(aws lambda get-function --function-name "${{ inputs.function_name }}" --query 'Configuration.FunctionArn' --output text)
case "$FN_ARN" in
arn:aws:lambda:*:*:function:*:*) BASE_ARN="${FN_ARN%%:*}" ;;
*) BASE_ARN="$FN_ARN" ;;
esac
echo "Using base Lambda ARN: ${BASE_ARN}"
aws cloudformation deploy \
--stack-name "$STACK" \
--template-file infra/apigw-rest-demo.yaml \
--parameter-overrides \
FunctionArn="${{ steps.demo-lambda.outputs.function-arn }}" \
FunctionArn="${BASE_ARN}" \
ApiName="${API_NAME}" \
StageName="${STAGE_NAME}" \
--no-fail-on-empty-changeset
--no-fail-on-empty-changeset \
|| { echo "Demo deploy failed; recent stack events:"; aws cloudformation describe-stack-events --stack-name "$STACK" --output text | head -n 200 || true; exit 1; }

EP=$(aws cloudformation describe-stacks --stack-name "$STACK" --query 'Stacks[0].Outputs[?OutputKey==`ApiEndpoint`].OutputValue' --output text)
echo "Demo API endpoint: $EP"
Expand Down Expand Up @@ -120,7 +156,9 @@ jobs:
exit 0
fi
FN_NAME="${FN_ARN##*:function:}"
LG="/aws/lambda/${FN_NAME}"
# Strip version or alias suffix (e.g., myfn:10 -> myfn)
FN_BASE="${FN_NAME%%:*}"
LG="/aws/lambda/${FN_BASE}"
echo "Fetching latest log stream from ${LG} ..."
LS=$(aws logs describe-log-streams --log-group-name "$LG" --order-by LastEventTime --descending --max-items 1 --query 'logStreams[0].logStreamName' --output text 2>/dev/null || true)
if [ -n "${LS:-}" ] && [ "${LS}" != "None" ]; then
Expand Down
59 changes: 49 additions & 10 deletions cmd/demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,69 @@ package main
import (
"context"
"encoding/json"
"strings"

"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)

type helloResp struct {
Message string `json:"message"`
}

func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
// Minimal REST v1 proxy handler
// Serve simple HTML for browser-friendly responses at / and /hello
path := req.Path
// Serve at /hello and /
if path == "/hello" || path == "/" {
b, _ := json.Marshal(helloResp{Message: "hello"})
// Check query param and Accept header for JSON preference
qs := req.QueryStringParameters["format"]
accept := ""
for k, v := range req.Headers {
if strings.EqualFold(k, "Accept") {
accept = v
break
}
}

wantJSON := false
if strings.EqualFold(qs, "json") {
wantJSON = true
} else if accept != "" {
if strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html") {
wantJSON = true
}
}

if wantJSON {
b, _ := json.Marshal(map[string]string{"message": "hello"})
return events.APIGatewayProxyResponse{
StatusCode: 200,
Headers: map[string]string{"Content-Type": "application/json"},
MultiValueHeaders: map[string][]string{},
Body: string(b),
IsBase64Encoded: false,
}, nil
}

html := `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello</title>
<style>body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial;margin:40px}</style>
</head>
<body>
<h1>Hello</h1>
<p>This is the demo Lambda served through API Gateway (REST).</p>
</body>
</html>`

return events.APIGatewayProxyResponse{
StatusCode: 200,
Headers: map[string]string{"Content-Type": "application/json"},
Headers: map[string]string{"Content-Type": "text/html; charset=utf-8"},
MultiValueHeaders: map[string][]string{},
Body: string(b),
Body: html,
IsBase64Encoded: false,
}, nil
}
// 404 for others

// 404 for others (JSON)
return events.APIGatewayProxyResponse{
StatusCode: 404,
Headers: map[string]string{"Content-Type": "application/json"},
Expand Down
84 changes: 78 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strconv"
"time"

"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
Expand All @@ -25,6 +26,15 @@ var (
commitHash string
)

// local random source to avoid using the deprecated global seed
var rnd *rand.Rand

func init() {
if rnd == nil {
rnd = rand.New(rand.NewSource(time.Now().UnixNano()))
}
}

// RandomString struct for individual random strings
type RandomString struct {
Length int `json:"length"`
Expand All @@ -51,7 +61,7 @@ func GenerateRandomPrintable(length int) string {
specialChars := []rune("!#$%*+-=?@^_")

// Determine how many characters to replace (1 to 3, but not more than the string length).
numReplacements := rand.Intn(3) + 1
numReplacements := rnd.Intn(3) + 1
if numReplacements >= length {
numReplacements = 1
}
Expand All @@ -63,8 +73,8 @@ func GenerateRandomPrintable(length int) string {

// Replace characters at random positions.
for i := 0; i < numReplacements; i++ {
pos := rand.Intn(length)
runes[pos] = specialChars[rand.Intn(len(specialChars))]
pos := rnd.Intn(length)
runes[pos] = specialChars[rnd.Intn(len(specialChars))]
}

return string(runes)
Expand All @@ -75,7 +85,7 @@ func GenerateRandomAlphanumeric(length int) string {
letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
result := make([]rune, length)
for i := range result {
result[i] = letters[rand.Intn(len(letters))]
result[i] = letters[rnd.Intn(len(letters))]
}
return string(result)
}
Expand Down Expand Up @@ -180,6 +190,8 @@ func generateStrings(c *gin.Context) {

func main() {
gin.SetMode(gin.ReleaseMode)
// Initialize a local random source for non-deterministic output across cold starts
rnd = rand.New(rand.NewSource(time.Now().UnixNano()))
r := gin.Default()

// Define the endpoints
Expand Down Expand Up @@ -234,7 +246,7 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte,

// Fallback to API Gateway v1
var v1req events.APIGatewayProxyRequest
if err := json.Unmarshal(payload, &v1req); err == nil && v1req.RequestContext.RequestID != "" {
if err := json.Unmarshal(payload, &v1req); err == nil && (v1req.HTTPMethod != "" || v1req.Path != "" || v1req.RequestContext.RequestID != "") {
resp, err := h.v1.ProxyWithContext(ctx, v1req)
if err != nil {
return nil, err
Expand All @@ -250,7 +262,67 @@ func (h *universalHandler) Invoke(ctx context.Context, payload []byte) ([]byte,
return json.Marshal(resp)
}

return nil, fmt.Errorf("unsupported event payload for Lambda handler")
// Permissive fallback: some console or custom test events use non-standard shapes.
// Try to coerce generic JSON payloads into v2 or v1 shapes by inspecting keys.
var generic map[string]interface{}
if err := json.Unmarshal(payload, &generic); err == nil {
// Detect v2-like (version = "2.0" or requestContext.http present)
if v, ok := generic["version"].(string); ok && v == "2.0" {
if b, err := json.Marshal(generic); err == nil {
var v2req events.APIGatewayV2HTTPRequest
if err := json.Unmarshal(b, &v2req); err == nil {
resp, err := h.v2.ProxyWithContext(ctx, v2req)
if err != nil {
return nil, err
}
return json.Marshal(resp)
}
}
}

if _, hasHTTPMethod := generic["httpMethod"]; hasHTTPMethod || generic["path"] != nil || generic["resource"] != nil {
if b, err := json.Marshal(generic); err == nil {
var v1 events.APIGatewayProxyRequest
if err := json.Unmarshal(b, &v1); err == nil {
resp, err := h.v1.ProxyWithContext(ctx, v1)
if err != nil {
return nil, err
}
if resp.Headers == nil {
resp.Headers = map[string]string{}
}
if resp.MultiValueHeaders == nil {
resp.MultiValueHeaders = map[string][]string{}
}
resp.IsBase64Encoded = false
return json.Marshal(resp)
}
}
}
}

// Final permissive fallback: forward raw payload as v1 POST / body so console tests succeed.
// This keeps the function usable from the Lambda console with arbitrary test events.
v1fallback := events.APIGatewayProxyRequest{
Path: "/json",
HTTPMethod: "GET",
Headers: map[string]string{"Content-Type": "application/json"},
MultiValueHeaders: map[string][]string{},
Body: string(payload),
IsBase64Encoded: false,
}
resp, err := h.v1.ProxyWithContext(ctx, v1fallback)
if err != nil {
return nil, fmt.Errorf("unable to coerce generic payload to v1 proxy: %w", err)
}
if resp.Headers == nil {
resp.Headers = map[string]string{}
}
if resp.MultiValueHeaders == nil {
resp.MultiValueHeaders = map[string][]string{}
}
resp.IsBase64Encoded = false
return json.Marshal(resp)
}

// convertFunctionURLToV2 maps a Lambda Function URL event to an APIGateway v2 HTTP request for the adapter.
Expand Down
Loading