diff --git a/handler/mued.go b/handler/mued.go index 78b983a..53c4c73 100644 --- a/handler/mued.go +++ b/handler/mued.go @@ -72,6 +72,20 @@ func (h *MuEdHandler) checkMuEdVersion(w http.ResponseWriter, r *http.Request) ( return runtime.MuEdResolveVersion(requested), true } +// writeMuEdError writes a structured muEd JSON error response with X-Api-Version header. +func (h *MuEdHandler) writeMuEdError(w http.ResponseWriter, version string, statusCode int, code, title, message string, details map[string]any) { + body, _ := json.Marshal(map[string]any{ + "title": title, + "message": message, + "code": code, + "details": details, + }) + w.Header().Set(muEdVersionHeader, version) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + w.Write(body) //nolint:errcheck +} + 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)) @@ -99,13 +113,13 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - writeJSONError(w, "failed to read body", http.StatusBadRequest) + h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", "failed to read body", nil) return } var muEdReq runtime.MuEdEvaluateRequest if err := json.Unmarshal(body, &muEdReq); err != nil { - writeJSONError(w, "invalid request body", http.StatusBadRequest) + h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", "invalid request body", nil) return } @@ -118,13 +132,13 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { legacyBody, err = runtime.MuEdBuildLegacyEvaluateRequest(muEdReq) } if err != nil { - writeJSONError(w, err.Error(), http.StatusBadRequest) + h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", err.Error(), nil) return } legacyBodyBytes, err := json.Marshal(legacyBody) if err != nil { - writeJSONError(w, "failed to build request", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "failed to build request", nil) return } @@ -151,6 +165,7 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { w.Header().Add(k, vv) } } + w.Header().Set(muEdVersionHeader, version) w.WriteHeader(resp.StatusCode) w.Write(resp.Body) //nolint:errcheck return @@ -158,13 +173,13 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) { var respBody map[string]any if err := json.Unmarshal(resp.Body, &respBody); err != nil { - writeJSONError(w, "failed to parse response", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "failed to parse response", nil) return } result, ok := respBody["result"].(map[string]any) if !ok { - writeJSONError(w, "invalid response from evaluation function", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "invalid response from evaluation function", nil) return } @@ -202,20 +217,25 @@ func (h *MuEdHandler) ServeHealth(w http.ResponseWriter, r *http.Request) { Data: map[string]any{}, }) if err != nil { - http.Error(w, "health check failed", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "health check failed", nil) return } legacyResult, ok := resp["result"].(map[string]any) if !ok { - http.Error(w, "invalid health response", http.StatusInternalServerError) + h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", "invalid health response", nil) return } result := runtime.MuEdToHealthResponse(legacyResult) + statusCode := http.StatusOK + if s, ok := result["status"].(string); ok && s == "UNAVAILABLE" { + statusCode = http.StatusServiceUnavailable + } + w.Header().Set("Content-Type", "application/json") w.Header().Set(muEdVersionHeader, version) - w.WriteHeader(http.StatusOK) + w.WriteHeader(statusCode) json.NewEncoder(w).Encode(result) //nolint:errcheck } diff --git a/handler/mued_test.go b/handler/mued_test.go index bdbdcfd..64ae2c8 100644 --- a/handler/mued_test.go +++ b/handler/mued_test.go @@ -208,25 +208,45 @@ func TestMuEdServeEvaluate_InvalidJSON(t *testing.T) { newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req) - assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusBadRequest, 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, "VALIDATION_ERROR", body["code"]) + mockHandler.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) } func TestMuEdServeEvaluate_MissingReferenceSolution(t *testing.T) { mockHandler := new(MockHandler) - body, _ := json.Marshal(map[string]any{ + reqBody, _ := json.Marshal(map[string]any{ "submission": map[string]any{ "type": "MATH", "content": map[string]any{"expression": "x^2"}, }, }) - req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(reqBody)) w := httptest.NewRecorder() newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req) - assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusBadRequest, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) + + var errBody map[string]any + require.NoError(t, json.Unmarshal(raw, &errBody)) + assert.Equal(t, "VALIDATION_ERROR", errBody["code"]) + mockHandler.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything) } @@ -251,6 +271,7 @@ func TestMuEdServeEvaluate_WorkerErrorForwarded(t *testing.T) { raw, _ := io.ReadAll(res.Body) assert.Equal(t, http.StatusInternalServerError, res.StatusCode) + assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version")) assert.Equal(t, errorBody, bytes.TrimRight(raw, "\n")) } @@ -327,7 +348,47 @@ func TestMuEdServeHealth_RuntimeError(t *testing.T) { newMuEdHandler(nil, mockRuntime, "").ServeHealth(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) + res := w.Result() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusInternalServerError, 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, "INTERNAL_ERROR", body["code"]) + + mockRuntime.AssertExpectations(t) +} + +func TestMuEdServeHealth_DegradedStatus(t *testing.T) { + healthResult := map[string]any{"tests_passed": false, "successes": []any{}, "failures": []any{"f1"}, "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() + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + + assert.Equal(t, http.StatusOK, res.StatusCode) + 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, "DEGRADED", result["status"]) + mockRuntime.AssertExpectations(t) }