Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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: 29 additions & 9 deletions handler/mued.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -151,20 +165,21 @@ 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
}

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
}

Expand Down Expand Up @@ -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
}
71 changes: 66 additions & 5 deletions handler/mued_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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"))
}

Expand Down Expand Up @@ -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)
}

Expand Down
Loading