Skip to content
Merged
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
50 changes: 44 additions & 6 deletions handler/mued.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handler

import (
"encoding/json"
"fmt"
"io"
"net/http"

Expand All @@ -12,6 +13,8 @@ import (
"github.com/lambda-feedback/shimmy/runtime"
)

const muEdVersionHeader = "X-Api-Version"

type MuEdHandlerParams struct {
fx.In

Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
113 changes: 113 additions & 0 deletions handler/mued_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
39 changes: 39 additions & 0 deletions runtime/mued.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading