diff --git a/handler/mued.go b/handler/mued.go index 4c48786..78b983a 100644 --- a/handler/mued.go +++ b/handler/mued.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "io" "net/http" @@ -12,6 +13,8 @@ import ( "github.com/lambda-feedback/shimmy/runtime" ) +const muEdVersionHeader = "X-Api-Version" + type MuEdHandlerParams struct { fx.In @@ -43,6 +46,32 @@ func writeJSONError(w http.ResponseWriter, msg string, status int) { 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) { + requested := r.Header.Get(muEdVersionHeader) + if requested != "" && !runtime.MuEdIsVersionSupported(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, + ), + "code": "VERSION_NOT_SUPPORTED", + "details": map[string]any{ + "requestedVersion": requested, + "supportedVersions": runtime.SupportedMuEdVersions, + }, + }) + w.Header().Set(muEdVersionHeader, runtime.MuEdResolveVersion(requested)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotAcceptable) + w.Write(body) //nolint:errcheck + return "", false + } + return runtime.MuEdResolveVersion(requested), true +} + func (h *MuEdHandler) checkAuth(w http.ResponseWriter, r *http.Request) bool { if h.config.Auth.Key != "" && r.Header.Get("api-key") != h.config.Auth.Key { h.log.Debug("unauthorized request", zap.String("path", r.URL.Path)) @@ -58,6 +87,11 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { return } + version, ok := h.checkMuEdVersion(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -142,6 +176,7 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") + w.Header().Set(muEdVersionHeader, version) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(feedback) //nolint:errcheck } @@ -152,6 +187,11 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { return } + version, ok := h.checkMuEdVersion(w, r) + if !ok { + return + } + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -166,18 +206,16 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { return } - result, ok := resp["result"].(map[string]any) + legacyResult, ok := resp["result"].(map[string]any) if !ok { http.Error(w, "invalid health response", http.StatusInternalServerError) return } - status := "DEGRADED" - if testsPassed, _ := result["tests_passed"].(bool); testsPassed { - status = "OK" - } + result := runtime.MuEdToHealthResponse(legacyResult) w.Header().Set("Content-Type", "application/json") + w.Header().Set(muEdVersionHeader, version) w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{"status": status}) //nolint:errcheck + json.NewEncoder(w).Encode(result) //nolint:errcheck } diff --git a/handler/mued_test.go b/handler/mued_test.go index 4b16850..bdbdcfd 100644 --- a/handler/mued_test.go +++ b/handler/mued_test.go @@ -99,6 +99,7 @@ func TestMuEdServeEvaluate_Success(t *testing.T) { assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) var feedback []map[string]any require.NoError(t, json.Unmarshal(body, &feedback)) @@ -277,10 +278,16 @@ func TestMuEdServeHealth_Success(t *testing.T) { assert.Equal(t, http.StatusOK, res.StatusCode) assert.Equal(t, "application/json", res.Header.Get("Content-Type")) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) var result map[string]any require.NoError(t, json.Unmarshal(raw, &result)) assert.Equal(t, "OK", result["status"]) + caps, ok := result["capabilities"].(map[string]any) + require.True(t, ok) + versions, ok := caps["supportedAPIVersions"].([]any) + require.True(t, ok) + assert.Contains(t, versions, "0.1.0") mockRuntime.AssertExpectations(t) } @@ -323,3 +330,109 @@ func TestMuEdServeHealth_RuntimeError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) mockRuntime.AssertExpectations(t) } + +// --- Version header tests (ServeEvaluate) --- + +func TestMuEdServeEvaluate_AbsentVersionHeader(t *testing.T) { + mockHandler := new(MockHandler) + mockHandler.On("Handle", mock.Anything, mock.Anything). + Return(evalHandlerResponse(true, "ok")) + + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mathEvalBody(t))) + w := httptest.NewRecorder() + + newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req) + + res := w.Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) +} + +func TestMuEdServeEvaluate_SupportedVersionHeader(t *testing.T) { + mockHandler := new(MockHandler) + mockHandler.On("Handle", mock.Anything, mock.Anything). + Return(evalHandlerResponse(true, "ok")) + + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mathEvalBody(t))) + req.Header.Set("X-Api-Version", "0.1.0") + w := httptest.NewRecorder() + + newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req) + + res := w.Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) +} + +func TestMuEdServeEvaluate_UnsupportedVersionHeader(t *testing.T) { + mockHandler := new(MockHandler) + + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mathEvalBody(t))) + req.Header.Set("X-Api-Version", "99.0.0") + w := httptest.NewRecorder() + + newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusNotAcceptable, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + assert.Equal(t, "VERSION_NOT_SUPPORTED", body["code"]) + details, ok := body["details"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "99.0.0", details["requestedVersion"]) + + mockHandler.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) +} + +// --- Version header tests (ServeHealth) --- + +func TestMuEdServeHealth_AbsentVersionHeader(t *testing.T) { + healthResult := map[string]any{"tests_passed": true, "successes": []any{}, "failures": []any{}, "errors": []any{}} + mockRuntime := new(MockRuntime) + mockRuntime.On("Handle", mock.Anything, runtime.EvaluationRequest{ + Command: runtime.CommandHealth, + Data: map[string]any{}, + }).Return(runtime.EvaluationResponse{ + "command": "healthcheck", + "result": healthResult, + }, nil) + + req := httptest.NewRequest(http.MethodGet, "/evaluate/health", nil) + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeHealth(w, req) + + res := w.Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + mockRuntime.AssertExpectations(t) +} + +func TestMuEdServeHealth_UnsupportedVersionHeader(t *testing.T) { + mockRuntime := new(MockRuntime) + + req := httptest.NewRequest(http.MethodGet, "/evaluate/health", nil) + req.Header.Set("X-Api-Version", "99.0.0") + w := httptest.NewRecorder() + + newMuEdHandler(nil, mockRuntime, "").ServeHealth(w, req) + + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusNotAcceptable, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + + var body map[string]any + require.NoError(t, json.Unmarshal(raw, &body)) + assert.Equal(t, "VERSION_NOT_SUPPORTED", body["code"]) + + mockRuntime.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) +} diff --git a/runtime/mued.go b/runtime/mued.go index 363ae8a..b88343c 100644 --- a/runtime/mued.go +++ b/runtime/mued.go @@ -36,6 +36,45 @@ type MuEdEvaluateRequest struct { PreSubmissionFeedback *MuEdPreSubmissionFeedback `json:"preSubmissionFeedback"` } +var SupportedMuEdVersions = []string{"0.1.0"} + +// MuEdIsVersionSupported reports whether version is in SupportedMuEdVersions. +func MuEdIsVersionSupported(version string) bool { + for _, v := range SupportedMuEdVersions { + if v == version { + return true + } + } + return false +} + +// MuEdResolveVersion returns requested if it's supported, else the latest version. +func MuEdResolveVersion(requested string) string { + if MuEdIsVersionSupported(requested) { + return requested + } + return SupportedMuEdVersions[len(SupportedMuEdVersions)-1] +} + +// MuEdToHealthResponse converts a legacy runtime health result to muEd format. +func MuEdToHealthResponse(result map[string]any) map[string]any { + status := "DEGRADED" + if passed, ok := result["tests_passed"].(bool); ok && passed { + status = "OK" + } + return map[string]any{ + "status": status, + "capabilities": map[string]any{ + "supportsEvaluate": true, + "supportsPreSubmissionFeedback": true, + "supportsFormativeFeedback": true, + "supportsSummativeFeedback": false, + "supportsDataPolicy": "NOT_SUPPORTED", + "supportedAPIVersions": SupportedMuEdVersions, + }, + } +} + func muEdContentKey(t MuEdSubmissionType) string { switch t { case MuEdMath: