adapters/apigateway/v1/gateway.go

// Package v1 adapts lambdalog to API Gateway REST API (v1) events. The
// shape of the event differs from HTTP API v2 enough that a separate
// adapter is simpler than a parameterised one.
//
// See mercemay.top/src/lambdalog/adapters/apigateway/v1/.
package v1

import (
	"context"
	"time"

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

// Logger is the narrow interface consumed by the adapter.
type Logger interface {
	Info(msg string, fields ...Field)
	Error(msg string, fields ...Field)
	With(fields ...Field) Logger
}

// Field mirrors encoder.Field.
type Field struct {
	Key   string
	Value any
}

// Handler is the shape of a user handler for REST API v1.
type Handler func(context.Context, events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)

// Wrap returns a Handler that logs around next. Each call produces a single
// "http.request" record with the status, path, and elapsed milliseconds.
func Wrap(logger Logger, next Handler) Handler {
	return func(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
		start := time.Now()
		l := logger.With(
			Field{Key: "method", Value: req.HTTPMethod},
			Field{Key: "path", Value: req.Path},
			Field{Key: "stage", Value: req.RequestContext.Stage},
		)
		l.Info("http.request.start")
		resp, err := next(ctx, req)
		dur := time.Since(start)
		status := resp.StatusCode
		if status == 0 {
			status = 500
		}
		if err != nil {
			l.Error("http.request.end",
				Field{Key: "status", Value: status},
				Field{Key: "duration_ms", Value: dur.Milliseconds()},
				Field{Key: "error", Value: err.Error()})
		} else {
			l.Info("http.request.end",
				Field{Key: "status", Value: status},
				Field{Key: "duration_ms", Value: dur.Milliseconds()})
		}
		return resp, err
	}
}

// ExtractFields reads common identifying fields out of req. It is exported
// so callers building their own wrappers can reuse the logic.
func ExtractFields(req events.APIGatewayProxyRequest) []Field {
	fs := []Field{
		{Key: "method", Value: req.HTTPMethod},
		{Key: "path", Value: req.Path},
		{Key: "stage", Value: req.RequestContext.Stage},
	}
	if id := req.RequestContext.RequestID; id != "" {
		fs = append(fs, Field{Key: "api_request_id", Value: id})
	}
	if ip := req.RequestContext.Identity.SourceIP; ip != "" {
		fs = append(fs, Field{Key: "source_ip", Value: ip})
	}
	return fs
}

// ResponseSize approximates the emitted body length. It is intentionally a
// best-effort estimate: base64 payloads are counted as their decoded
// length.
func ResponseSize(resp events.APIGatewayProxyResponse) int {
	if resp.IsBase64Encoded {
		return (len(resp.Body) * 3) / 4
	}
	return len(resp.Body)
}

// ErrorResponse returns a well-formed response body for the given code.
// The adapter uses it when the wrapped handler panics so clients do not
// receive a raw 502 from the Lambda runtime.
func ErrorResponse(status int, body string) events.APIGatewayProxyResponse {
	return events.APIGatewayProxyResponse{
		StatusCode: status,
		Body:       body,
		Headers:    map[string]string{"Content-Type": "text/plain"},
	}
}