Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 17 additions & 21 deletions app/lambda/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ type LambdaHandlerParams struct {
// Config is the configuration for the Lambda handler.
Config Config

// Handlers is a slice of HTTP handlers grouped together.
Handlers []*server.HttpHandler `group:"handlers"`
// Mux is the shared, fully-wrapped application HTTP handler chain — the same
// one the standalone server serves, including OpenAPI request/response
// validation.
Mux *server.Mux

// Context is the context for the Lambda handler.
Context context.Context
Expand All @@ -32,30 +34,24 @@ type LambdaHandlerParams struct {
}

type LambdaHandler struct {
config Config
ctx context.Context
cancel context.CancelFunc
mux *http.ServeMux
log *zap.Logger
config Config
ctx context.Context
cancel context.CancelFunc
handler http.Handler
log *zap.Logger
}

// NewLambdaHandler creates a new instance of LambdaHandler
// with the given parameters.
func NewLambdaHandler(params LambdaHandlerParams) *LambdaHandler {
ctx, cancel := context.WithCancel(params.Context)

mux := http.NewServeMux()

for _, handler := range params.Handlers {
mux.Handle(handler.Name, handler.Handler)
}

return &LambdaHandler{
config: params.Config,
ctx: ctx,
cancel: cancel,
mux: mux,
log: params.Logger,
config: params.Config,
ctx: ctx,
cancel: cancel,
handler: params.Mux,
log: params.Logger,
}
}

Expand Down Expand Up @@ -101,11 +97,11 @@ func (s *LambdaHandler) Shutdown() {
func (s *LambdaHandler) getProxyFunction() (any, error) {
switch s.config.ProxySource {
case ProxySourceApiGatewayV1:
return httpadapter.New(server.NormalizePath(s.mux)).ProxyWithContext, nil
return httpadapter.New(s.handler).ProxyWithContext, nil
case ProxySourceApiGatewayV2:
return httpadapter.NewV2(server.NormalizePath(s.mux)).ProxyWithContext, nil
return httpadapter.NewV2(s.handler).ProxyWithContext, nil
case ProxySourceAlb:
return httpadapter.NewALB(server.NormalizePath(s.mux)).ProxyWithContext, nil
return httpadapter.NewALB(s.handler).ProxyWithContext, nil
default:
return nil, fmt.Errorf("invalid proxy source: %s", s.config.ProxySource)
}
Expand Down
3 changes: 3 additions & 0 deletions app/lambda/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"go.uber.org/fx"

"github.com/lambda-feedback/shimmy/handler"
"github.com/lambda-feedback/shimmy/internal/server"
"github.com/lambda-feedback/shimmy/util/logging"
)

Expand All @@ -16,6 +17,8 @@ func Module(config Config) fx.Option {
logging.DecorateLogger("lambda"),
// provide handlers
handler.Module(),
// provide the shared HTTP handler chain (specs + wrapped mux)
server.HandlerModule(),
// provide server
fx.Provide(NewLifecycleHandler),
// invoke server
Expand Down
28 changes: 28 additions & 0 deletions app/lambda/module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package lambda

import (
"context"
"testing"

"go.uber.org/fx"
"go.uber.org/zap"

"github.com/lambda-feedback/shimmy/config"
"github.com/lambda-feedback/shimmy/runtime"
"github.com/stretchr/testify/assert"
)

// TestModule_DependencyGraphValid makes sure the Lambda fx graph resolves —
// notably that it now pulls in server.HandlerModule so the Lambda adapter serves
// the same OpenAPI-validated handler chain as the standalone server.
func TestModule_DependencyGraphValid(t *testing.T) {
err := fx.ValidateApp(
fx.NopLogger,
fx.Supply(fx.Annotate(context.Background(), fx.As(new(context.Context)))),
fx.Supply(zap.NewNop()),
fx.Supply(config.Config{}),
runtime.Module(config.Config{}.Runtime),
Module(Config{}),
)
assert.NoError(t, err)
}
28 changes: 28 additions & 0 deletions app/standalone/module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package standalone

import (
"context"
"testing"

"go.uber.org/fx"
"go.uber.org/zap"

"github.com/lambda-feedback/shimmy/config"
"github.com/lambda-feedback/shimmy/runtime"
"github.com/stretchr/testify/assert"
)

// TestModule_DependencyGraphValid makes sure the standalone fx graph — the µEd
// handlers, the per-version OpenAPI specs, and the µEd version registry provided
// by runtime.Module — resolves without missing or cyclic dependencies.
func TestModule_DependencyGraphValid(t *testing.T) {
err := fx.ValidateApp(
fx.NopLogger,
fx.Supply(fx.Annotate(context.Background(), fx.As(new(context.Context)))),
fx.Supply(zap.NewNop()),
fx.Supply(config.Config{}),
runtime.Module(config.Config{}.Runtime),
Module(Config{}),
)
assert.NoError(t, err)
}
16 changes: 5 additions & 11 deletions handler/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) {
return
}

version, ok := h.checkMuEdVersion(w, r)
version, adapter, ok := h.checkMuEdVersion(w, r)
if !ok {
return
}
Expand All @@ -31,13 +31,7 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) {
return
}

var chatReq runtime.MuEdChatRequest
if err := json.Unmarshal(body, &chatReq); err != nil {
h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", "invalid request body", nil)
return
}

reqData, err := runtime.MuEdBuildChatRequest(chatReq)
reqData, err := adapter.DecodeChat(body)
if err != nil {
h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", err.Error(), nil)
return
Expand All @@ -55,7 +49,7 @@ func (h *MuEdHandler) ServeChat(w http.ResponseWriter, r *http.Request) {
return
}

chatResp, err := runtime.MuEdToChatResponse(resultMap)
chatResp, err := adapter.EncodeChat(resultMap)
if err != nil {
h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", err.Error(), nil)
return
Expand All @@ -73,7 +67,7 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) {
return
}

version, ok := h.checkMuEdVersion(w, r)
version, adapter, ok := h.checkMuEdVersion(w, r)
if !ok {
return
}
Expand All @@ -95,7 +89,7 @@ func (h *MuEdHandler) ServeChatHealth(w http.ResponseWriter, r *http.Request) {
return
}

healthResp := runtime.MuEdToChatHealthResponse(resultMap)
healthResp := adapter.EncodeChatHealth(resultMap)

statusCode := http.StatusOK
if status, ok := healthResp["status"].(string); ok && status == string(runtime.MuEdChatHealthStatusUnavailable) {
Expand Down
92 changes: 44 additions & 48 deletions handler/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,58 +19,73 @@ const muEdVersionHeader = "X-Api-Version"
type MuEdHandlerParams struct {
fx.In

Handler runtime.Handler
Runtime runtime.Runtime
Config config.Config
Log *zap.Logger
Handler runtime.Handler
Runtime runtime.Runtime
Registry *runtime.MuEdRegistry
Config config.Config
Log *zap.Logger
}

type MuEdHandler struct {
handler runtime.Handler
runtime runtime.Runtime
config config.Config
log *zap.Logger
handler runtime.Handler
runtime runtime.Runtime
registry *runtime.MuEdRegistry
config config.Config
log *zap.Logger
}

func NewMuEdHandler(params MuEdHandlerParams) *MuEdHandler {
return &MuEdHandler{
handler: params.Handler,
runtime: params.Runtime,
config: params.Config,
log: params.Log,
handler: params.Handler,
runtime: params.Runtime,
registry: params.Registry,
config: params.Config,
log: params.Log,
}
}

// muEdRegistry returns the handler's version registry, falling back to the
// process-wide default when none was injected (e.g. in unit tests).
func (h *MuEdHandler) muEdRegistry() *runtime.MuEdRegistry {
if h.registry != nil {
return h.registry
}
return runtime.DefaultMuEdRegistry()
}

func writeJSONError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": msg}}) //nolint:errcheck
}

// checkMuEdVersion validates the X-Api-Version request header.
// Returns (resolvedVersion, true) on success, or writes a 406 and returns ("", false).
func (h *MuEdHandler) checkMuEdVersion(w http.ResponseWriter, r *http.Request) (string, bool) {
// checkMuEdVersion validates the X-Api-Version request header and resolves it to
// a concrete version adapter. Returns (resolvedVersion, adapter, true) on
// success, or writes a 406 and returns ("", nil, false).
func (h *MuEdHandler) checkMuEdVersion(w http.ResponseWriter, r *http.Request) (string, runtime.MuEdAdapter, bool) {
reg := h.muEdRegistry()
requested := r.Header.Get(muEdVersionHeader)
if requested != "" && !runtime.MuEdIsVersionSupported(requested) {
if requested != "" && !reg.Supports(requested) {
body, _ := json.Marshal(map[string]any{
"title": "API version not supported",
"message": fmt.Sprintf(
"The requested API version '%s' is not supported. Supported versions are: %v.",
requested, runtime.SupportedMuEdVersions,
requested, reg.Versions(),
),
"code": "VERSION_NOT_SUPPORTED",
"details": map[string]any{
"requestedVersion": requested,
"supportedVersions": runtime.SupportedMuEdVersions,
"supportedVersions": reg.Versions(),
},
})
w.Header().Set(muEdVersionHeader, runtime.MuEdResolveVersion(requested))
w.Header().Set(muEdVersionHeader, reg.Resolve(requested))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotAcceptable)
w.Write(body) //nolint:errcheck
return "", false
return "", nil, false
}
return runtime.MuEdResolveVersion(requested), true
version := reg.Resolve(requested)
return version, reg.Adapter(version), true
}

// writeMuEdError writes a structured muEd JSON error response with X-Api-Version header.
Expand Down Expand Up @@ -102,7 +117,7 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) {
return
}

version, ok := h.checkMuEdVersion(w, r)
version, adapter, ok := h.checkMuEdVersion(w, r)
if !ok {
return
}
Expand All @@ -118,20 +133,7 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) {
return
}

var muEdReq runtime.MuEdEvaluateRequest
if err := json.Unmarshal(body, &muEdReq); err != nil {
h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", "invalid request body", nil)
return
}

isPreview := muEdReq.PreSubmissionFeedback != nil && muEdReq.PreSubmissionFeedback.Enabled

var legacyBody map[string]any
if isPreview {
legacyBody, err = runtime.MuEdBuildLegacyPreviewRequest(muEdReq)
} else {
legacyBody, err = runtime.MuEdBuildLegacyEvaluateRequest(muEdReq)
}
legacyBody, command, err := adapter.DecodeEvaluate(body)
if err != nil {
h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", err.Error(), nil)
return
Expand All @@ -143,11 +145,6 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) {
return
}

command := runtime.CommandEvaluate
if isPreview {
command = runtime.CommandPreview
}

header := http.Header{}
header.Set("Command", string(command))

Expand Down Expand Up @@ -184,11 +181,10 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) {
return
}

var feedback []map[string]any
if isPreview {
feedback = runtime.MuEdToPreviewFeedback(result)
} else {
feedback = runtime.MuEdToEvaluateFeedback(result)
feedback, err := adapter.EncodeEvaluateFeedback(command, result)
if err != nil {
h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "failed to build feedback", nil)
return
}

w.Header().Set("Content-Type", "application/json")
Expand All @@ -203,7 +199,7 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) {
return
}

version, ok := h.checkMuEdVersion(w, r)
version, adapter, ok := h.checkMuEdVersion(w, r)
if !ok {
return
}
Expand All @@ -228,7 +224,7 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) {
return
}

result := runtime.MuEdToHealthResponse(legacyResult)
result := adapter.EncodeHealth(legacyResult)

statusCode := http.StatusOK
if s, ok := result["status"].(string); ok && s == "UNAVAILABLE" {
Expand Down
Loading
Loading