diff --git a/app/lambda/handler.go b/app/lambda/handler.go index c230576..45139c0 100644 --- a/app/lambda/handler.go +++ b/app/lambda/handler.go @@ -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 @@ -32,11 +34,11 @@ 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 @@ -44,18 +46,12 @@ type LambdaHandler struct { 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, } } @@ -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) } diff --git a/app/lambda/module.go b/app/lambda/module.go index 1ed820a..1300a97 100644 --- a/app/lambda/module.go +++ b/app/lambda/module.go @@ -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" ) @@ -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 diff --git a/app/lambda/module_test.go b/app/lambda/module_test.go new file mode 100644 index 0000000..9b72dd0 --- /dev/null +++ b/app/lambda/module_test.go @@ -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) +} diff --git a/app/standalone/module_test.go b/app/standalone/module_test.go new file mode 100644 index 0000000..08d9715 --- /dev/null +++ b/app/standalone/module_test.go @@ -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) +} diff --git a/handler/chat.go b/handler/chat.go index 24e9e26..593c434 100644 --- a/handler/chat.go +++ b/handler/chat.go @@ -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 } @@ -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 @@ -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 @@ -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 } @@ -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) { diff --git a/handler/evaluate.go b/handler/evaluate.go index c462b17..1654dd7 100644 --- a/handler/evaluate.go +++ b/handler/evaluate.go @@ -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. @@ -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 } @@ -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 @@ -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)) @@ -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") @@ -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 } @@ -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" { diff --git a/handler/mued_version_test.go b/handler/mued_version_test.go new file mode 100644 index 0000000..b33eddc --- /dev/null +++ b/handler/mued_version_test.go @@ -0,0 +1,145 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/lambda-feedback/shimmy/config" + "github.com/lambda-feedback/shimmy/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// markerAdapter is a synthetic MuEdAdapter whose outputs are easy to recognise, +// used to prove the handler dispatches to the resolved version's adapter rather +// than to hard-coded v0.1.0 logic. +type markerAdapter struct{} + +func (markerAdapter) Version() string { return "9.9.9" } +func (markerAdapter) DecodeEvaluate([]byte) (map[string]any, runtime.Command, error) { + return map[string]any{"marker": "decoded"}, runtime.CommandEvaluate, nil +} +func (markerAdapter) EncodeEvaluateFeedback(runtime.Command, map[string]any) ([]map[string]any, error) { + return []map[string]any{{"marker": "feedback-9.9.9"}}, nil +} +func (markerAdapter) EncodeHealth(map[string]any) map[string]any { + return map[string]any{"marker": "health-9.9.9"} +} +func (markerAdapter) DecodeChat([]byte) (map[string]any, error) { + return map[string]any{"marker": "chat"}, nil +} +func (markerAdapter) EncodeChat(map[string]any) (map[string]any, error) { + return map[string]any{"marker": "chat-9.9.9"}, nil +} +func (markerAdapter) EncodeChatHealth(map[string]any) map[string]any { + return map[string]any{"marker": "chat-health-9.9.9"} +} + +func newMuEdHandlerWithRegistry(h runtime.Handler, r runtime.Runtime, reg *runtime.MuEdRegistry) *MuEdHandler { + return &MuEdHandler{ + handler: h, + runtime: r, + registry: reg, + config: config.Config{}, + log: zap.NewNop(), + } +} + +// TestMuEdServeEvaluate_DispatchesToResolvedAdapter proves version dispatch: +// an X-Api-Version the injected registry supports is routed to that version's +// adapter, and the response echoes the resolved version. +func TestMuEdServeEvaluate_DispatchesToResolvedAdapter(t *testing.T) { + reg := runtime.NewMuEdRegistry() + reg.Register(markerAdapter{}) + + mockHandler := new(MockHandler) + mockHandler.On("Handle", mock.Anything, mock.Anything).Return(evalHandlerResponse(true, "ignored")) + + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mathEvalBody(t))) + req.Header.Set("X-Api-Version", "9.9.9") + w := httptest.NewRecorder() + + newMuEdHandlerWithRegistry(mockHandler, nil, reg).ServeEvaluate(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "9.9.9", res.Header.Get("X-Api-Version")) + + var feedback []map[string]any + require.NoError(t, json.Unmarshal(raw, &feedback)) + require.Len(t, feedback, 1) + assert.Equal(t, "feedback-9.9.9", feedback[0]["marker"]) + + mockHandler.AssertExpectations(t) +} + +// TestMuEdServeEvaluate_VersionParity runs the happy path with both an absent +// header and an explicit supported header and asserts identical output. +func TestMuEdServeEvaluate_VersionParity(t *testing.T) { + run := func(setHeader bool) (int, string, []map[string]any) { + mockHandler := new(MockHandler) + mockHandler.On("Handle", mock.Anything, mock.Anything). + Return(evalHandlerResponse(true, "Well done")) + + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mathEvalBody(t))) + if setHeader { + req.Header.Set("X-Api-Version", "0.1.0") + } + w := httptest.NewRecorder() + newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + var fb []map[string]any + require.NoError(t, json.Unmarshal(raw, &fb)) + return res.StatusCode, res.Header.Get("X-Api-Version"), fb + } + + absentCode, absentVer, absentFb := run(false) + explicitCode, explicitVer, explicitFb := run(true) + + assert.Equal(t, http.StatusOK, absentCode) + assert.Equal(t, absentCode, explicitCode) + assert.Equal(t, "0.1.0", absentVer) + assert.Equal(t, absentVer, explicitVer) + assert.Equal(t, absentFb, explicitFb) +} + +// TestMuEdServeChat_DispatchesToResolvedAdapter is the chat-side counterpart. +func TestMuEdServeChat_DispatchesToResolvedAdapter(t *testing.T) { + reg := runtime.NewMuEdRegistry() + reg.Register(markerAdapter{}) + + mockRuntime := new(MockRuntime) + mockRuntime.On("Chat", mock.Anything, mock.Anything). + Return(chatRuntimeResponse("ASSISTANT", "ignored"), nil) + + req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(chatRequestBody(t))) + req.Header.Set("X-Api-Version", "9.9.9") + w := httptest.NewRecorder() + + newMuEdHandlerWithRegistry(nil, mockRuntime, reg).ServeChat(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "9.9.9", res.Header.Get("X-Api-Version")) + + var resp map[string]any + require.NoError(t, json.Unmarshal(raw, &resp)) + assert.Equal(t, "chat-9.9.9", resp["marker"]) + + mockRuntime.AssertExpectations(t) +} diff --git a/internal/server/module.go b/internal/server/module.go index 7644bed..96c503d 100644 --- a/internal/server/module.go +++ b/internal/server/module.go @@ -2,12 +2,26 @@ package server import "go.uber.org/fx" +// HandlerModule provides the shared application HTTP handler chain — the +// per-version OpenAPI specs and the wrapped Mux. Both the standalone server and +// the Lambda adapter depend on it so they serve an identical, identically +// validated handler. +func HandlerModule() fx.Option { + return fx.Module("http-handler", + // provide openapi specs (one per supported µEd version) + fx.Provide(LoadOpenAPISpecs), + // provide the wrapped handler chain + fx.Provide(NewMux), + ) +} + +// Module provides the standalone HTTP server on top of HandlerModule. func Module(config HttpConfig) fx.Option { return fx.Module("server", // provide config fx.Supply(config), - // provide openapi spec - fx.Provide(LoadOpenAPISpec), + // provide the shared handler chain + HandlerModule(), // provide server fx.Provide(NewLifecycleServer), // invoke server diff --git a/internal/server/mux.go b/internal/server/mux.go new file mode 100644 index 0000000..20bd110 --- /dev/null +++ b/internal/server/mux.go @@ -0,0 +1,44 @@ +package server + +import ( + "fmt" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "go.uber.org/fx" + "go.uber.org/zap" +) + +// MuxParams are the dependency-injected pieces the shared HTTP handler chain is +// built from. +type MuxParams struct { + fx.In + + Specs map[string]*openapi3.T + Handlers []*HttpHandler `group:"handlers"` + Logger *zap.Logger +} + +// Mux is the fully-wrapped application HTTP handler: the route mux, path +// normalisation, and per-version OpenAPI request/response validation. Both the +// standalone server and the Lambda adapter serve this exact chain, so the two +// deployments validate requests and responses identically. +type Mux struct { + http.Handler +} + +// NewMux assembles the shared HTTP handler chain from the registered route +// handlers and the embedded OpenAPI specs. +func NewMux(params MuxParams) (*Mux, error) { + mux := http.NewServeMux() + for _, h := range params.Handlers { + mux.Handle(h.Name, h.Handler) + } + + openAPIMiddleware, err := OpenAPIMiddleware(params.Specs, nil, params.Logger) + if err != nil { + return nil, fmt.Errorf("initialising OpenAPI middleware: %w", err) + } + + return &Mux{Handler: openAPIMiddleware(NormalizePath(mux))}, nil +} diff --git a/internal/server/mux_test.go b/internal/server/mux_test.go new file mode 100644 index 0000000..7d664e6 --- /dev/null +++ b/internal/server/mux_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// TestNewMux_AppliesValidationAndNormalisation proves the shared chain both +// deployments serve actually wraps the route handlers with OpenAPI validation +// and path normalisation. +func TestNewMux_AppliesValidationAndNormalisation(t *testing.T) { + specs, err := LoadOpenAPISpecs() + require.NoError(t, err) + + var gotPath string + evaluate := &HttpHandler{ + Name: "/evaluate", + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) //nolint:errcheck + }), + } + + mux, err := NewMux(MuxParams{ + Specs: specs, + Handlers: []*HttpHandler{evaluate}, + Logger: zap.NewNop(), + }) + require.NoError(t, err) + + t.Run("valid request is normalised and reaches the handler", func(t *testing.T) { + gotPath = "" + body := mustJSON(t, map[string]any{ + "submission": map[string]any{"type": "TEXT", "content": map[string]any{"text": "hi"}}, + }) + req := httptest.NewRequest(http.MethodPost, "/myFunction/evaluate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "/evaluate", gotPath, "NormalizePath should rewrite the prefixed path") + }) + + t.Run("spec-violating request is rejected before the handler", func(t *testing.T) { + gotPath = "" + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Empty(t, gotPath, "handler must not be reached") + }) + + t.Run("unknown route passes through", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/not-a-mued-route", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // no handler registered for this path -> mux 404, but the middleware + // must not have turned it into a 400/500 + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} diff --git a/internal/server/openapi.go b/internal/server/openapi.go index 8d8ed88..1fff705 100644 --- a/internal/server/openapi.go +++ b/internal/server/openapi.go @@ -9,35 +9,85 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" "github.com/getkin/kin-openapi/routers/legacy" "go.uber.org/zap" + "github.com/lambda-feedback/shimmy/runtime" "github.com/lambda-feedback/shimmy/runtime/schema" ) -func LoadOpenAPISpec() (*openapi3.T, error) { +func loadSpec(data []byte) (*openapi3.T, error) { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true - spec, err := loader.LoadFromData(schema.OpenAPISpec) + spec, err := loader.LoadFromData(data) if err != nil { - return nil, fmt.Errorf("loading OpenAPI spec: %w", err) + return nil, err } // Skip validation for OpenAPI 3.1.0 — the legacy router validates on NewRouter. return spec, nil } -func OpenAPIMiddleware(spec *openapi3.T, log *zap.Logger) (func(http.Handler) http.Handler, error) { - router, err := legacy.NewRouter(spec, - openapi3.IsOpenAPI31OrLater(), - openapi3.AllowExtraSiblingFields("description", "summary"), - ) +// LoadOpenAPISpec loads the latest embedded µEd OpenAPI spec. +func LoadOpenAPISpec() (*openapi3.T, error) { + spec, err := loadSpec(schema.OpenAPISpec) if err != nil { - return nil, fmt.Errorf("creating OpenAPI router: %w", err) + return nil, fmt.Errorf("loading OpenAPI spec: %w", err) + } + return spec, nil +} + +// LoadOpenAPISpecs loads every embedded µEd OpenAPI spec, keyed by version. +func LoadOpenAPISpecs() (map[string]*openapi3.T, error) { + out := make(map[string]*openapi3.T, len(schema.MuEdOpenAPISpecs)) + for version, data := range schema.MuEdOpenAPISpecs { + spec, err := loadSpec(data) + if err != nil { + return nil, fmt.Errorf("loading OpenAPI spec %s: %w", version, err) + } + out[version] = spec + } + return out, nil +} + +// OpenAPIMiddleware validates µEd requests and responses against the OpenAPI +// spec for the version the client is targeting. The spec is selected from the +// X-Api-Version header via resolveVersion — the same resolver the handlers use — +// so a request is validated against exactly the version that will serve it. A +// nil resolveVersion defaults to runtime.MuEdResolveVersion. Routes that no +// selected spec defines (e.g. the legacy "/" route) pass through unvalidated. +func OpenAPIMiddleware(specs map[string]*openapi3.T, resolveVersion func(string) string, log *zap.Logger) (func(http.Handler) http.Handler, error) { + if len(specs) == 0 { + return nil, fmt.Errorf("no OpenAPI specs provided") + } + if resolveVersion == nil { + resolveVersion = runtime.MuEdResolveVersion + } + + routerByVersion := make(map[string]routers.Router, len(specs)) + for version, spec := range specs { + router, err := legacy.NewRouter(spec, + openapi3.IsOpenAPI31OrLater(), + openapi3.AllowExtraSiblingFields("description", "summary"), + ) + if err != nil { + return nil, fmt.Errorf("creating OpenAPI router for %s: %w", version, err) + } + routerByVersion[version] = router } opts := &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc} return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + version := resolveVersion(r.Header.Get("X-Api-Version")) + router, ok := routerByVersion[version] + if !ok { + // No spec for the resolved version — cannot validate, pass through. + // The handler still rejects genuinely unsupported versions with a 406. + next.ServeHTTP(w, r) + return + } + route, pathParams, err := router.FindRoute(r) if err != nil { // Not a µEd route — pass through unvalidated @@ -64,8 +114,7 @@ func OpenAPIMiddleware(spec *openapi3.T, log *zap.Logger) (func(http.Handler) ht // Snapshot body before validation — ValidateResponse drains the buffer. bodyBytes := rec.Body.Bytes() - - // Validate response (lenient — log only) + // Validate response respInput := &openapi3filter.ResponseValidationInput{ RequestValidationInput: reqInput, Status: rec.Code, diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index 554f354..eb67028 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -5,8 +5,10 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" + "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -18,30 +20,50 @@ func TestLoadOpenAPISpec(t *testing.T) { assert.NotNil(t, spec) } +func TestLoadOpenAPISpecs(t *testing.T) { + specs, err := LoadOpenAPISpecs() + require.NoError(t, err) + require.NotEmpty(t, specs) + assert.Contains(t, specs, "0.1.0") + for version, spec := range specs { + assert.NotNilf(t, spec, "spec for %s", version) + } +} + func TestOpenAPIMiddleware_Init(t *testing.T) { - spec, err := LoadOpenAPISpec() + specs, err := LoadOpenAPISpecs() require.NoError(t, err) - middleware, err := OpenAPIMiddleware(spec, zap.NewNop()) + middleware, err := OpenAPIMiddleware(specs, nil, zap.NewNop()) require.NoError(t, err) assert.NotNil(t, middleware) } +func TestOpenAPIMiddleware_NoSpecs_Errors(t *testing.T) { + _, err := OpenAPIMiddleware(map[string]*openapi3.T{}, nil, zap.NewNop()) + assert.Error(t, err) +} + func TestOpenAPIMiddleware_UnknownRoute_PassesThrough(t *testing.T) { middleware := mustMiddleware(t) - called := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - w.WriteHeader(http.StatusOK) - }) + for _, version := range []string{"", "0.1.0", "0.2.0", "9.9.9"} { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) - req := httptest.NewRequest(http.MethodGet, "/not-a-mued-route", nil) - w := httptest.NewRecorder() - middleware(next).ServeHTTP(w, req) + req := httptest.NewRequest(http.MethodGet, "/not-a-mued-route", nil) + if version != "" { + req.Header.Set("X-Api-Version", version) + } + w := httptest.NewRecorder() + middleware(next).ServeHTTP(w, req) - assert.True(t, called, "next handler should be called for unknown route") - assert.Equal(t, http.StatusOK, w.Code) + assert.Truef(t, called, "next handler should be called for unknown route (version %q)", version) + assert.Equal(t, http.StatusOK, w.Code) + } } func TestOpenAPIMiddleware_ValidRequest_ReachesHandler(t *testing.T) { @@ -125,11 +147,11 @@ func TestOpenAPIMiddleware_ValidHealthRequest_ReachesHandler(t *testing.T) { w.Write(mustJSON(t, map[string]any{ //nolint:errcheck "status": "OK", "capabilities": map[string]any{ - "supportsEvaluate": true, + "supportsEvaluate": true, "supportsPreSubmissionFeedback": false, - "supportsFormativeFeedback": true, - "supportsSummativeFeedback": true, - "supportsDataPolicy": "NOT_SUPPORTED", + "supportsFormativeFeedback": true, + "supportsSummativeFeedback": true, + "supportsDataPolicy": "NOT_SUPPORTED", }, })) }) @@ -142,12 +164,85 @@ func TestOpenAPIMiddleware_ValidHealthRequest_ReachesHandler(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } -// mustMiddleware loads the real spec and returns the initialised middleware, failing the test on error. +// TestOpenAPIMiddleware_VersionSelectsSpec proves the middleware validates a +// request against the spec for the version the client targets: the same +// /evaluate body is accepted under v0.1.0 but rejected under the synthetic +// v0.2.0 spec, which additionally requires "extraField". +func TestOpenAPIMiddleware_VersionSelectsSpec(t *testing.T) { + bodyNoExtra := map[string]any{ + "submission": map[string]any{"type": "TEXT", "content": map[string]any{"text": "hi"}}, + } + bodyWithExtra := map[string]any{ + "submission": map[string]any{"type": "TEXT", "content": map[string]any{"text": "hi"}}, + "extraField": "present", + } + + tests := []struct { + name string + version string + body map[string]any + wantCode int + wantHandler bool + }{ + {"v0.1.0 accepts body without extraField", "0.1.0", bodyNoExtra, http.StatusOK, true}, + {"no header resolves to v0.1.0 and accepts", "", bodyNoExtra, http.StatusOK, true}, + {"v0.2.0 rejects body without extraField", "0.2.0", bodyNoExtra, http.StatusBadRequest, false}, + {"v0.2.0 accepts body with extraField", "0.2.0", bodyWithExtra, http.StatusOK, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + middleware := mustMiddleware(t) + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[]`)) //nolint:errcheck + }) + + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mustJSON(t, tc.body))) + req.Header.Set("Content-Type", "application/json") + if tc.version != "" { + req.Header.Set("X-Api-Version", tc.version) + } + w := httptest.NewRecorder() + middleware(next).ServeHTTP(w, req) + + assert.Equal(t, tc.wantCode, w.Code) + assert.Equal(t, tc.wantHandler, called) + }) + } +} + +// mustMiddleware loads the real v0.1.0 spec plus the synthetic v0.2.0 testdata +// spec and returns the initialised middleware, with a resolver that mirrors +// runtime.MuEdRegistry.Resolve for the order [0.1.0, 0.2.0]. func mustMiddleware(t *testing.T) func(http.Handler) http.Handler { t.Helper() - spec, err := LoadOpenAPISpec() + + specs, err := LoadOpenAPISpecs() + require.NoError(t, err) + + data, err := os.ReadFile("testdata/mued_v0.2.0.yml") require.NoError(t, err) - middleware, err := OpenAPIMiddleware(spec, zap.NewNop()) + v020, err := loadSpec(data) + require.NoError(t, err) + specs["0.2.0"] = v020 + + resolve := func(v string) string { + switch v { + case "": + return "0.1.0" // Default(): first registered, pinned + case "0.1.0", "0.2.0": + return v + default: + return "0.2.0" // Latest() + } + } + + middleware, err := OpenAPIMiddleware(specs, resolve, zap.NewNop()) require.NoError(t, err) return middleware } @@ -158,4 +253,4 @@ func mustJSON(t *testing.T, v any) []byte { b, err := json.Marshal(v) require.NoError(t, err) return b -} \ No newline at end of file +} diff --git a/internal/server/server.go b/internal/server/server.go index 6a94ea5..bbea100 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -6,7 +6,6 @@ import ( "net" "net/http" - "github.com/getkin/kin-openapi/openapi3" "go.uber.org/fx" "go.uber.org/zap" "golang.org/x/net/http2" @@ -19,10 +18,9 @@ type HttpServerParams struct { Context context.Context Config HttpConfig - Spec *openapi3.T + Mux *Mux - Handlers []*HttpHandler `group:"handlers"` - Logger *zap.Logger + Logger *zap.Logger } type HttpServer struct { @@ -33,19 +31,8 @@ type HttpServer struct { log *zap.Logger } -func NewHttpServer(params HttpServerParams) (*HttpServer, error) { - mux := http.NewServeMux() - - for _, handler := range params.Handlers { - mux.Handle(handler.Name, handler.Handler) - } - - var handler http.Handler = NormalizePath(mux) - openAPIMiddleware, err := OpenAPIMiddleware(params.Spec, params.Logger) - if err != nil { - return nil, fmt.Errorf("initialising OpenAPI middleware: %w", err) - } - handler = openAPIMiddleware(handler) +func NewHttpServer(params HttpServerParams) *HttpServer { + var handler http.Handler = params.Mux if params.Config.H2c { handler = h2c.NewHandler(handler, &http2.Server{}) } @@ -61,14 +48,11 @@ func NewHttpServer(params HttpServerParams) (*HttpServer, error) { port: params.Config.Port, server: server, log: params.Logger, - }, nil + } } -func NewLifecycleServer(params HttpServerParams, lc fx.Lifecycle) (*HttpServer, error) { - server, err := NewHttpServer(params) - if err != nil { - return nil, err - } +func NewLifecycleServer(params HttpServerParams, lc fx.Lifecycle) *HttpServer { + server := NewHttpServer(params) lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go server.Serve(ctx) @@ -78,7 +62,7 @@ func NewLifecycleServer(params HttpServerParams, lc fx.Lifecycle) (*HttpServer, return server.Shutdown(ctx) }, }) - return server, nil + return server } func (s *HttpServer) Serve(context.Context) error { diff --git a/internal/server/testdata/mued_v0.2.0.yml b/internal/server/testdata/mued_v0.2.0.yml new file mode 100644 index 0000000..e16112f --- /dev/null +++ b/internal/server/testdata/mued_v0.2.0.yml @@ -0,0 +1,43 @@ +# Synthetic µEd spec used only by openapi_test.go to exercise per-version spec +# selection. It is a deliberately trimmed OpenAPI 3.1.0 document whose only +# meaningful difference from v0.1.0 is that POST /evaluate additionally requires +# an "extraField" property. +openapi: 3.1.0 +info: + title: Synthetic µEd test spec + version: 0.2.0 +paths: + /evaluate: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - submission + - extraField + properties: + submission: + type: object + extraField: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + /evaluate/health: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: object diff --git a/runtime/evaluate.go b/runtime/evaluate.go index bc9e868..1085363 100644 --- a/runtime/evaluate.go +++ b/runtime/evaluate.go @@ -50,7 +50,7 @@ func MuEdToHealthResponse(result map[string]any) map[string]any { "supportsFormativeFeedback": true, "supportsSummativeFeedback": false, "supportsDataPolicy": "NOT_SUPPORTED", - "supportedAPIVersions": SupportedMuEdVersions, + "supportedAPIVersions": SupportedMuEdVersions(), }, } } diff --git a/runtime/module.go b/runtime/module.go index fa46f32..d6dbea7 100644 --- a/runtime/module.go +++ b/runtime/module.go @@ -10,6 +10,9 @@ func Module(config Config) fx.Option { // provide runtime config fx.Supply(config), + // provide the µEd version adapter registry + fx.Provide(DefaultMuEdRegistry), + // provide runtime fx.Provide(NewLifecycleRuntime), diff --git a/runtime/mued_adapter.go b/runtime/mued_adapter.go new file mode 100644 index 0000000..b23f5c4 --- /dev/null +++ b/runtime/mued_adapter.go @@ -0,0 +1,117 @@ +package runtime + +// MuEdAdapter translates between one specific µEd API version's wire format and +// the legacy worker protocol. Exactly one implementation is registered per +// supported version; the HTTP handlers resolve the client's X-Api-Version to an +// adapter and drive it, staying version-agnostic themselves. +// +// Decode (not just transform) sits behind this interface on purpose: a future +// version with a different request shape owns its own json.Unmarshal target and +// its own preview-detection rule without the handlers changing. +type MuEdAdapter interface { + // Version is the µEd API version this adapter implements, e.g. "0.1.0". + Version() string + + // DecodeEvaluate parses a POST /evaluate request body into the legacy + // worker request map and the command to run — CommandEvaluate or + // CommandPreview. The adapter owns preview detection. + DecodeEvaluate(body []byte) (legacy map[string]any, command Command, err error) + + // EncodeEvaluateFeedback converts a legacy worker result into the µEd + // feedback array for the command DecodeEvaluate returned. + EncodeEvaluateFeedback(command Command, result map[string]any) ([]map[string]any, error) + + // EncodeHealth converts a legacy health result into the µEd health response. + EncodeHealth(legacyResult map[string]any) map[string]any + + // DecodeChat parses a POST /chat request body into the worker request map. + DecodeChat(body []byte) (map[string]any, error) + + // EncodeChat converts a worker chat result into the µEd chat response. + EncodeChat(result map[string]any) (map[string]any, error) + + // EncodeChatHealth converts a worker chat health result into the µEd chat + // health response. + EncodeChatHealth(result map[string]any) map[string]any +} + +// MuEdRegistry holds the µEd version adapters known to the process, in +// registration order (oldest first). +type MuEdRegistry struct { + order []string + byVersion map[string]MuEdAdapter +} + +// NewMuEdRegistry returns an empty registry. +func NewMuEdRegistry() *MuEdRegistry { + return &MuEdRegistry{byVersion: map[string]MuEdAdapter{}} +} + +// Register adds an adapter. Registering a version again replaces the earlier +// adapter but keeps its position in the order. +func (r *MuEdRegistry) Register(a MuEdAdapter) { + v := a.Version() + if _, seen := r.byVersion[v]; !seen { + r.order = append(r.order, v) + } + r.byVersion[v] = a +} + +// Versions returns the supported versions in registration order. +func (r *MuEdRegistry) Versions() []string { + out := make([]string, len(r.order)) + copy(out, r.order) + return out +} + +// Supports reports whether version is registered. +func (r *MuEdRegistry) Supports(version string) bool { + _, ok := r.byVersion[version] + return ok +} + +// Latest is the most recently registered version, or "" when the registry is +// empty. Used only as the value stamped on a 406 response for an unsupported +// version. +func (r *MuEdRegistry) Latest() string { + if len(r.order) == 0 { + return "" + } + return r.order[len(r.order)-1] +} + +// Default is the version used when a client sends no X-Api-Version header: the +// first registered version. Pinned deliberately — it does not track Latest, so +// registering a newer version never silently moves header-less clients onto new +// semantics. Bump it in its own change. +func (r *MuEdRegistry) Default() string { + if len(r.order) == 0 { + return "" + } + return r.order[0] +} + +// Resolve maps a requested version to a concrete supported one: the default +// version when requested is empty, the request itself when supported, otherwise +// the latest supported version. +func (r *MuEdRegistry) Resolve(requested string) string { + if requested == "" { + return r.Default() + } + if r.Supports(requested) { + return requested + } + return r.Latest() +} + +// Adapter returns the adapter for an already-resolved version, or nil. +func (r *MuEdRegistry) Adapter(version string) MuEdAdapter { + return r.byVersion[version] +} + +// defaultMuEdRegistry is the process-wide registry. Version adapter files +// register into it from their init(); see mued_v0_1_0.go. +var defaultMuEdRegistry = NewMuEdRegistry() + +// DefaultMuEdRegistry returns the process-wide µEd version registry. +func DefaultMuEdRegistry() *MuEdRegistry { return defaultMuEdRegistry } diff --git a/runtime/mued_adapter_test.go b/runtime/mued_adapter_test.go new file mode 100644 index 0000000..054a206 --- /dev/null +++ b/runtime/mued_adapter_test.go @@ -0,0 +1,94 @@ +package runtime_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lambda-feedback/shimmy/runtime" +) + +// fakeAdapter is a minimal MuEdAdapter used to exercise multi-version registry +// behaviour without a second real µEd version. +type fakeAdapter struct{ version string } + +func (a fakeAdapter) Version() string { return a.version } +func (a fakeAdapter) DecodeEvaluate([]byte) (map[string]any, runtime.Command, error) { + return map[string]any{"from": a.version}, runtime.CommandEvaluate, nil +} +func (a fakeAdapter) EncodeEvaluateFeedback(runtime.Command, map[string]any) ([]map[string]any, error) { + return []map[string]any{{"from": a.version}}, nil +} +func (a fakeAdapter) EncodeHealth(map[string]any) map[string]any { + return map[string]any{"from": a.version} +} +func (a fakeAdapter) DecodeChat([]byte) (map[string]any, error) { + return map[string]any{"from": a.version}, nil +} +func (a fakeAdapter) EncodeChat(map[string]any) (map[string]any, error) { + return map[string]any{"from": a.version}, nil +} +func (a fakeAdapter) EncodeChatHealth(map[string]any) map[string]any { + return map[string]any{"from": a.version} +} + +func TestMuEdRegistry_OrderAndResolution(t *testing.T) { + reg := runtime.NewMuEdRegistry() + reg.Register(fakeAdapter{version: "0.1.0"}) + reg.Register(fakeAdapter{version: "0.2.0"}) + reg.Register(fakeAdapter{version: "0.3.0"}) + + assert.Equal(t, []string{"0.1.0", "0.2.0", "0.3.0"}, reg.Versions()) + assert.Equal(t, "0.1.0", reg.Default(), "default is the first registered version, pinned") + assert.Equal(t, "0.3.0", reg.Latest()) + + assert.True(t, reg.Supports("0.2.0")) + assert.False(t, reg.Supports("9.9.9")) + + assert.Equal(t, "0.1.0", reg.Resolve(""), "empty request resolves to the pinned default") + assert.Equal(t, "0.2.0", reg.Resolve("0.2.0"), "supported request resolves to itself") + assert.Equal(t, "0.3.0", reg.Resolve("9.9.9"), "unsupported request resolves to latest") +} + +func TestMuEdRegistry_Adapter(t *testing.T) { + reg := runtime.NewMuEdRegistry() + reg.Register(fakeAdapter{version: "0.1.0"}) + reg.Register(fakeAdapter{version: "9.9.9"}) + + got := reg.Adapter("9.9.9") + require.NotNil(t, got) + assert.Equal(t, "9.9.9", got.Version()) + + feedback, err := got.EncodeEvaluateFeedback(runtime.CommandEvaluate, nil) + require.NoError(t, err) + require.Len(t, feedback, 1) + assert.Equal(t, "9.9.9", feedback[0]["from"]) + + assert.Nil(t, reg.Adapter("0.5.0"), "unknown version has no adapter") +} + +func TestMuEdRegistry_ReregisterKeepsPosition(t *testing.T) { + reg := runtime.NewMuEdRegistry() + reg.Register(fakeAdapter{version: "0.1.0"}) + reg.Register(fakeAdapter{version: "0.2.0"}) + reg.Register(fakeAdapter{version: "0.1.0"}) // replace, don't reorder + + assert.Equal(t, []string{"0.1.0", "0.2.0"}, reg.Versions()) +} + +func TestDefaultMuEdRegistry_HasV010(t *testing.T) { + reg := runtime.DefaultMuEdRegistry() + + assert.Equal(t, []string{"0.1.0"}, reg.Versions()) + assert.Equal(t, []string{"0.1.0"}, runtime.SupportedMuEdVersions()) + assert.True(t, runtime.MuEdIsVersionSupported("0.1.0")) + assert.False(t, runtime.MuEdIsVersionSupported("99.0.0")) + + assert.Equal(t, "0.1.0", runtime.MuEdResolveVersion("")) + assert.Equal(t, "0.1.0", runtime.MuEdResolveVersion("0.1.0")) + assert.Equal(t, "0.1.0", runtime.MuEdResolveVersion("99.0.0")) + + require.NotNil(t, reg.Adapter("0.1.0")) + assert.Equal(t, "0.1.0", reg.Adapter("0.1.0").Version()) +} diff --git a/runtime/mued_v0_1_0.go b/runtime/mued_v0_1_0.go new file mode 100644 index 0000000..b16422d --- /dev/null +++ b/runtime/mued_v0_1_0.go @@ -0,0 +1,61 @@ +package runtime + +import ( + "encoding/json" + "fmt" +) + +// muEdV010 is the MuEdAdapter for µEd API version 0.1.0. Every method delegates +// to the package-level transform functions in evaluate.go / chat.go, so 0.1.0 +// behaviour is exactly what it was before the adapter layer existed. +type muEdV010 struct{} + +var _ MuEdAdapter = muEdV010{} + +func init() { + defaultMuEdRegistry.Register(muEdV010{}) +} + +func (muEdV010) Version() string { return "0.1.0" } + +func (muEdV010) DecodeEvaluate(body []byte) (map[string]any, Command, error) { + var req MuEdEvaluateRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, "", fmt.Errorf("invalid request body") + } + + if req.PreSubmissionFeedback != nil && req.PreSubmissionFeedback.Enabled { + legacy, err := MuEdBuildLegacyPreviewRequest(req) + return legacy, CommandPreview, err + } + + legacy, err := MuEdBuildLegacyEvaluateRequest(req) + return legacy, CommandEvaluate, err +} + +func (muEdV010) EncodeEvaluateFeedback(command Command, result map[string]any) ([]map[string]any, error) { + if command == CommandPreview { + return MuEdToPreviewFeedback(result), nil + } + return MuEdToEvaluateFeedback(result), nil +} + +func (muEdV010) EncodeHealth(legacyResult map[string]any) map[string]any { + return MuEdToHealthResponse(legacyResult) +} + +func (muEdV010) DecodeChat(body []byte) (map[string]any, error) { + var req MuEdChatRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("invalid request body") + } + return MuEdBuildChatRequest(req) +} + +func (muEdV010) EncodeChat(result map[string]any) (map[string]any, error) { + return MuEdToChatResponse(result) +} + +func (muEdV010) EncodeChatHealth(result map[string]any) map[string]any { + return MuEdToChatHealthResponse(result) +} diff --git a/runtime/mued_v0_1_0_test.go b/runtime/mued_v0_1_0_test.go new file mode 100644 index 0000000..ad9087d --- /dev/null +++ b/runtime/mued_v0_1_0_test.go @@ -0,0 +1,118 @@ +package runtime_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lambda-feedback/shimmy/runtime" +) + +// The v0.1.0 adapter must be a pure delegation to the package-level transform +// functions — these tests are the byte-for-byte regression guard for that. + +func v010(t *testing.T) runtime.MuEdAdapter { + t.Helper() + a := runtime.DefaultMuEdRegistry().Adapter("0.1.0") + require.NotNil(t, a) + return a +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +func TestMuEdV010_DecodeEvaluate_MatchesFreeFunctions(t *testing.T) { + a := v010(t) + + evalReq := runtime.MuEdEvaluateRequest{ + Submission: runtime.MuEdSubmission{Type: runtime.MuEdMath, Content: map[string]any{"expression": "x^2"}}, + Task: &runtime.MuEdTask{ReferenceSolution: map[string]any{"expression": "x^2"}}, + } + evalBody := mustJSON(t, evalReq) + + gotLegacy, gotCmd, err := a.DecodeEvaluate([]byte(evalBody)) + require.NoError(t, err) + assert.Equal(t, runtime.CommandEvaluate, gotCmd) + wantLegacy, err := runtime.MuEdBuildLegacyEvaluateRequest(evalReq) + require.NoError(t, err) + assert.Equal(t, wantLegacy, gotLegacy) + + previewReq := runtime.MuEdEvaluateRequest{ + Submission: runtime.MuEdSubmission{Type: runtime.MuEdMath, Content: map[string]any{"expression": "x^2"}}, + PreSubmissionFeedback: &runtime.MuEdPreSubmissionFeedback{Enabled: true}, + } + previewBody := mustJSON(t, previewReq) + + gotLegacy, gotCmd, err = a.DecodeEvaluate([]byte(previewBody)) + require.NoError(t, err) + assert.Equal(t, runtime.CommandPreview, gotCmd) + wantLegacy, err = runtime.MuEdBuildLegacyPreviewRequest(previewReq) + require.NoError(t, err) + assert.Equal(t, wantLegacy, gotLegacy) +} + +func TestMuEdV010_DecodeEvaluate_Errors(t *testing.T) { + a := v010(t) + + _, _, err := a.DecodeEvaluate([]byte("not json")) + assert.Error(t, err) + + missingRef := mustJSON(t, runtime.MuEdEvaluateRequest{ + Submission: runtime.MuEdSubmission{Type: runtime.MuEdMath, Content: map[string]any{"expression": "x^2"}}, + }) + _, _, err = a.DecodeEvaluate([]byte(missingRef)) + assert.Error(t, err, "missing task.referenceSolution is a decode error") +} + +func TestMuEdV010_EncodeEvaluateFeedback_MatchesFreeFunctions(t *testing.T) { + a := v010(t) + + evalResult := map[string]any{"is_correct": true, "feedback": "Well done"} + gotFb, err := a.EncodeEvaluateFeedback(runtime.CommandEvaluate, evalResult) + require.NoError(t, err) + assert.Equal(t, runtime.MuEdToEvaluateFeedback(evalResult), gotFb) + + previewResult := map[string]any{"preview": map[string]any{"latex": "x^{2}"}} + gotFb, err = a.EncodeEvaluateFeedback(runtime.CommandPreview, previewResult) + require.NoError(t, err) + assert.Equal(t, runtime.MuEdToPreviewFeedback(previewResult), gotFb) +} + +func TestMuEdV010_EncodeHealth_MatchesFreeFunction(t *testing.T) { + a := v010(t) + + for _, passed := range []bool{true, false} { + result := map[string]any{"tests_passed": passed} + assert.Equal(t, runtime.MuEdToHealthResponse(result), a.EncodeHealth(result)) + } +} + +func TestMuEdV010_Chat_MatchesFreeFunctions(t *testing.T) { + a := v010(t) + + chatReq := runtime.MuEdChatRequest{Messages: []runtime.MuEdChatMessage{{Role: runtime.MuEdChatRoleUser, Content: "hi"}}} + gotData, err := a.DecodeChat([]byte(mustJSON(t, chatReq))) + require.NoError(t, err) + wantData, err := runtime.MuEdBuildChatRequest(chatReq) + require.NoError(t, err) + assert.Equal(t, wantData, gotData) + + _, err = a.DecodeChat([]byte(`{"messages":[]}`)) + assert.Error(t, err, "empty messages is a decode error") + + chatResult := map[string]any{"output": map[string]any{"role": "ASSISTANT", "content": "hello"}} + gotResp, err := a.EncodeChat(chatResult) + require.NoError(t, err) + wantResp, err := runtime.MuEdToChatResponse(chatResult) + require.NoError(t, err) + assert.Equal(t, wantResp, gotResp) + + healthResult := map[string]any{} + assert.Equal(t, runtime.MuEdToChatHealthResponse(healthResult), a.EncodeChatHealth(healthResult)) +} diff --git a/runtime/schema/openapi.go b/runtime/schema/openapi.go index b771890..e7548c3 100644 --- a/runtime/schema/openapi.go +++ b/runtime/schema/openapi.go @@ -1,6 +1,67 @@ package schema -import _ "embed" +import ( + "embed" + "fmt" + "sort" + "strings" +) -//go:embed mued_v0.1.0.yml -var OpenAPISpec []byte +// muEdSpecFS holds every embedded µEd OpenAPI spec. Files are named +// mued_v.yml; adding a new version is a matter of dropping in another +// such file — no Go change is required here. +// +//go:embed mued_v*.yml +var muEdSpecFS embed.FS + +// MuEdOpenAPISpecs maps µEd API version -> raw OpenAPI spec bytes, discovered +// from the embedded mued_v.yml files at package load. +var MuEdOpenAPISpecs = mustLoadMuEdSpecs() + +// OpenAPISpec is the latest embedded µEd OpenAPI spec, retained for callers that +// still expect a single spec blob. +var OpenAPISpec = MuEdOpenAPISpecs[LatestMuEdSpecVersion()] + +func mustLoadMuEdSpecs() map[string][]byte { + entries, err := muEdSpecFS.ReadDir(".") + if err != nil { + panic(fmt.Sprintf("reading embedded µEd specs: %v", err)) + } + + out := make(map[string][]byte) + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, "mued_v") || !strings.HasSuffix(name, ".yml") { + continue + } + version := strings.TrimSuffix(strings.TrimPrefix(name, "mued_v"), ".yml") + data, err := muEdSpecFS.ReadFile(name) + if err != nil { + panic(fmt.Sprintf("reading embedded µEd spec %s: %v", name, err)) + } + out[version] = data + } + + if len(out) == 0 { + panic("no embedded µEd OpenAPI specs found") + } + return out +} + +// MuEdSpecVersions returns the embedded spec versions in ascending order. +// Ordering is lexical, which is sufficient while versions stay single-digit; +// revisit if a component ever reaches double digits. +func MuEdSpecVersions() []string { + versions := make([]string, 0, len(MuEdOpenAPISpecs)) + for v := range MuEdOpenAPISpecs { + versions = append(versions, v) + } + sort.Strings(versions) + return versions +} + +// LatestMuEdSpecVersion returns the highest embedded spec version. +func LatestMuEdSpecVersion() string { + versions := MuEdSpecVersions() + return versions[len(versions)-1] +} diff --git a/runtime/version.go b/runtime/version.go index 1763f37..161103f 100644 --- a/runtime/version.go +++ b/runtime/version.go @@ -1,19 +1,19 @@ package runtime -var SupportedMuEdVersions = []string{"0.1.0"} +// SupportedMuEdVersions returns the µEd API versions this build supports, in +// registration order (oldest first). Backed by DefaultMuEdRegistry. +func SupportedMuEdVersions() []string { + return defaultMuEdRegistry.Versions() +} +// MuEdIsVersionSupported reports whether the given µEd API version is supported. func MuEdIsVersionSupported(version string) bool { - for _, v := range SupportedMuEdVersions { - if v == version { - return true - } - } - return false + return defaultMuEdRegistry.Supports(version) } +// MuEdResolveVersion maps a requested µEd API version to a concrete supported +// one: the default version when requested is empty, the request itself when +// supported, otherwise the latest supported version. func MuEdResolveVersion(requested string) string { - if MuEdIsVersionSupported(requested) { - return requested - } - return SupportedMuEdVersions[len(SupportedMuEdVersions)-1] + return defaultMuEdRegistry.Resolve(requested) }