Add response endpoint

Change-Id: I37fb32253d3011a8960a17852ea611443b9f093e
diff --git a/cmd/termmapper/fuzz_test.go b/cmd/termmapper/fuzz_test.go
index 650a08d..e2ac02d 100644
--- a/cmd/termmapper/fuzz_test.go
+++ b/cmd/termmapper/fuzz_test.go
@@ -150,6 +150,125 @@
 	})
 }
 
+func FuzzResponseTransformEndpoint(f *testing.F) {
+	// Create test mapping list
+	mappingList := tmconfig.MappingList{
+		ID:       "test-mapper",
+		FoundryA: "marmot",
+		LayerA:   "m",
+		FoundryB: "opennlp",
+		LayerB:   "p",
+		Mappings: []tmconfig.MappingRule{
+			"[gender=masc] <> [p=M & m=M]",
+		},
+	}
+
+	// Create mapper
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	if err != nil {
+		f.Fatal(err)
+	}
+
+	// Create mock config for testing
+	mockConfig := &tmconfig.MappingConfig{
+		Lists: []tmconfig.MappingList{mappingList},
+	}
+
+	// Create fiber app
+	app := fiber.New(fiber.Config{
+		DisableStartupMessage: true,
+		ErrorHandler: func(c *fiber.Ctx, err error) error {
+			// For body limit errors, return 413 status code
+			if err.Error() == "body size exceeds the given limit" || errors.Is(err, fiber.ErrRequestEntityTooLarge) {
+				return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
+					"error": fmt.Sprintf("request body too large (max %d bytes)", maxInputLength),
+				})
+			}
+			// For other errors, return 500 status code
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		},
+		BodyLimit: maxInputLength,
+	})
+	setupRoutes(app, m, mockConfig)
+
+	// Add seed corpus
+	f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"snippet": "<span>test</span>"}`))                               // Valid minimal input
+	f.Add("test-mapper", "btoa", "custom", "", "", "", []byte(`{"snippet": "<span title=\"test\">word</span>"}`))          // Valid with foundry override
+	f.Add("", "", "", "", "", "", []byte(`{}`))                                                                            // Empty parameters
+	f.Add("nonexistent", "invalid", "!@#$", "%^&*", "()", "[]", []byte(`invalid json`))                                    // Invalid everything
+	f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"snippet": null}`))                                              // Valid JSON with null snippet
+	f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"snippet": 123}`))                                               // Valid JSON with non-string snippet
+	f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"}`)) // Valid response snippet
+
+	f.Fuzz(func(t *testing.T, mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) {
+
+		// Validate input first
+		if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, body); err != nil {
+			// Skip this test case as it's invalid
+			t.Skip(err)
+		}
+
+		// Build URL with query parameters
+		params := url.Values{}
+		if dir != "" {
+			params.Set("dir", dir)
+		}
+		if foundryA != "" {
+			params.Set("foundryA", foundryA)
+		}
+		if foundryB != "" {
+			params.Set("foundryB", foundryB)
+		}
+		if layerA != "" {
+			params.Set("layerA", layerA)
+		}
+		if layerB != "" {
+			params.Set("layerB", layerB)
+		}
+
+		url := fmt.Sprintf("/%s/response", url.PathEscape(mapID))
+		if len(params) > 0 {
+			url += "?" + params.Encode()
+		}
+
+		// Make request
+		req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+		req.Header.Set("Content-Type", "application/json")
+		resp, err := app.Test(req)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer resp.Body.Close()
+
+		// Verify that we always get a valid response
+		if resp.StatusCode != http.StatusOK &&
+			resp.StatusCode != http.StatusBadRequest &&
+			resp.StatusCode != http.StatusInternalServerError {
+			t.Errorf("unexpected status code: %d", resp.StatusCode)
+		}
+
+		// Verify that the response is valid JSON
+		var result any
+		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+			t.Errorf("invalid JSON response: %v", err)
+		}
+
+		// For error responses, verify that we have an error message
+		if resp.StatusCode != http.StatusOK {
+			// For error responses, we expect a JSON object with an error field
+			if resultMap, ok := result.(map[string]any); ok {
+				if errMsg, ok := resultMap["error"].(string); !ok || errMsg == "" {
+					t.Error("error response missing error message")
+				}
+			} else {
+				t.Error("error response should be a JSON object")
+			}
+		}
+	})
+}
+
 func TestLargeInput(t *testing.T) {
 	// Create test mapping list
 	mappingList := tmconfig.MappingList{
diff --git a/cmd/termmapper/main.go b/cmd/termmapper/main.go
index 4c868b5..006a009 100644
--- a/cmd/termmapper/main.go
+++ b/cmd/termmapper/main.go
@@ -163,6 +163,9 @@
 	// Transformation endpoint
 	app.Post("/:map/query", handleTransform(m))
 
+	// Response transformation endpoint
+	app.Post("/:map/response", handleResponseTransform(m))
+
 	// Kalamar plugin endpoint
 	app.Get("/", handleKalamarPlugin(yamlConfig))
 	app.Get("/:map", handleKalamarPlugin(yamlConfig))
@@ -232,6 +235,70 @@
 	}
 }
 
+func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		// Get parameters
+		mapID := c.Params("map")
+		dir := c.Query("dir", "atob")
+		foundryA := c.Query("foundryA", "")
+		foundryB := c.Query("foundryB", "")
+		layerA := c.Query("layerA", "")
+		layerB := c.Query("layerB", "")
+
+		// Validate input parameters
+		if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		// Validate direction
+		if dir != "atob" && dir != "btoa" {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": "invalid direction, must be 'atob' or 'btoa'",
+			})
+		}
+
+		// Parse request body
+		var jsonData any
+		if err := c.BodyParser(&jsonData); err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": "invalid JSON in request body",
+			})
+		}
+
+		// Parse direction
+		direction, err := mapper.ParseDirection(dir)
+		if err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		// Apply response mappings
+		result, err := m.ApplyResponseMappings(mapID, mapper.MappingOptions{
+			Direction: direction,
+			FoundryA:  foundryA,
+			FoundryB:  foundryB,
+			LayerA:    layerA,
+			LayerB:    layerB,
+		}, jsonData)
+
+		if err != nil {
+			log.Error().Err(err).
+				Str("mapID", mapID).
+				Str("direction", dir).
+				Msg("Failed to apply response mappings")
+
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		return c.JSON(result)
+	}
+}
+
 // validateInput checks if the input parameters are valid
 func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
 	// Define parameter checks
@@ -337,6 +404,9 @@
 
 			<dt><tt><strong>POST</strong> /:map/query</tt></dt>
             <dd><small>Transform JSON query objects using term mapping rules</small></dd>
+
+			<dt><tt><strong>POST</strong> /:map/response</tt></dt>
+            <dd><small>Transform JSON response objects using term mapping rules</small></dd>
 			
         </dl>
 		
diff --git a/cmd/termmapper/main_test.go b/cmd/termmapper/main_test.go
index b248deb..8030b37 100644
--- a/cmd/termmapper/main_test.go
+++ b/cmd/termmapper/main_test.go
@@ -258,6 +258,191 @@
 	}
 }
 
+func TestResponseTransformEndpoint(t *testing.T) {
+	// Create test mapping list
+	mappingList := tmconfig.MappingList{
+		ID:       "test-response-mapper",
+		FoundryA: "marmot",
+		LayerA:   "m",
+		FoundryB: "opennlp",
+		LayerB:   "p",
+		Mappings: []tmconfig.MappingRule{
+			"[gender=masc] <> [p=M & m=M]",
+		},
+	}
+
+	// Create mapper
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+
+	// Create mock config for testing
+	mockConfig := &tmconfig.MappingConfig{
+		Lists: []tmconfig.MappingList{mappingList},
+	}
+
+	// Create fiber app
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	tests := []struct {
+		name          string
+		mapID         string
+		direction     string
+		foundryA      string
+		foundryB      string
+		layerA        string
+		layerB        string
+		input         string
+		expectedCode  int
+		expectedBody  string
+		expectedError string
+	}{
+		{
+			name:      "Simple response mapping with snippet transformation",
+			mapID:     "test-response-mapper",
+			direction: "atob",
+			input: `{
+				"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"
+			}`,
+			expectedCode: http.StatusOK,
+			expectedBody: `{
+				"snippet": "<span title=\"marmot/m:gender:masc\"><span title=\"opennlp/p:M\" class=\"notinindex\"><span title=\"opennlp/m:M\" class=\"notinindex\">Der</span></span></span>"
+			}`,
+		},
+		{
+			name:      "Response with no snippet field",
+			mapID:     "test-response-mapper",
+			direction: "atob",
+			input: `{
+				"@type": "koral:response",
+				"meta": {
+					"version": "Krill-0.64.1"
+				}
+			}`,
+			expectedCode: http.StatusOK,
+			expectedBody: `{
+				"@type": "koral:response",
+				"meta": {
+					"version": "Krill-0.64.1"
+				}
+			}`,
+		},
+		{
+			name:      "Response with null snippet",
+			mapID:     "test-response-mapper",
+			direction: "atob",
+			input: `{
+				"snippet": null
+			}`,
+			expectedCode: http.StatusOK,
+			expectedBody: `{
+				"snippet": null
+			}`,
+		},
+		{
+			name:      "Response with non-string snippet",
+			mapID:     "test-response-mapper",
+			direction: "atob",
+			input: `{
+				"snippet": 123
+			}`,
+			expectedCode: http.StatusOK,
+			expectedBody: `{
+				"snippet": 123
+			}`,
+		},
+		{
+			name:      "Response mapping with foundry override",
+			mapID:     "test-response-mapper",
+			direction: "atob",
+			foundryB:  "custom",
+			input: `{
+				"snippet": "<span title=\"marmot/m:gender:masc\">Der</span>"
+			}`,
+			expectedCode: http.StatusOK,
+			expectedBody: `{
+				"snippet": "<span title=\"marmot/m:gender:masc\"><span title=\"custom/p:M\" class=\"notinindex\"><span title=\"custom/m:M\" class=\"notinindex\">Der</span></span></span>"
+			}`,
+		},
+		{
+			name:          "Invalid mapping ID for response",
+			mapID:         "nonexistent",
+			direction:     "atob",
+			input:         `{"snippet": "<span>test</span>"}`,
+			expectedCode:  http.StatusInternalServerError,
+			expectedError: "mapping list with ID nonexistent not found",
+		},
+		{
+			name:          "Invalid direction for response",
+			mapID:         "test-response-mapper",
+			direction:     "invalid",
+			input:         `{"snippet": "<span>test</span>"}`,
+			expectedCode:  http.StatusBadRequest,
+			expectedError: "invalid direction, must be 'atob' or 'btoa'",
+		},
+		{
+			name:          "Invalid JSON for response",
+			mapID:         "test-response-mapper",
+			direction:     "atob",
+			input:         `{invalid json}`,
+			expectedCode:  http.StatusBadRequest,
+			expectedError: "invalid JSON in request body",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Build URL with query parameters
+			url := "/" + tt.mapID + "/response"
+			if tt.direction != "" {
+				url += "?dir=" + tt.direction
+			}
+			if tt.foundryA != "" {
+				url += "&foundryA=" + tt.foundryA
+			}
+			if tt.foundryB != "" {
+				url += "&foundryB=" + tt.foundryB
+			}
+			if tt.layerA != "" {
+				url += "&layerA=" + tt.layerA
+			}
+			if tt.layerB != "" {
+				url += "&layerB=" + tt.layerB
+			}
+
+			// Make request
+			req := httptest.NewRequest(http.MethodPost, url, bytes.NewBufferString(tt.input))
+			req.Header.Set("Content-Type", "application/json")
+			resp, err := app.Test(req)
+			require.NoError(t, err)
+			defer resp.Body.Close()
+
+			// Check status code
+			assert.Equal(t, tt.expectedCode, resp.StatusCode)
+
+			// Read response body
+			body, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+
+			if tt.expectedError != "" {
+				// Check error message
+				var errResp fiber.Map
+				err = json.Unmarshal(body, &errResp)
+				require.NoError(t, err)
+				assert.Equal(t, tt.expectedError, errResp["error"])
+			} else {
+				// Compare JSON responses
+				var expected, actual any
+				err = json.Unmarshal([]byte(tt.expectedBody), &expected)
+				require.NoError(t, err)
+				err = json.Unmarshal(body, &actual)
+				require.NoError(t, err)
+				assert.Equal(t, expected, actual)
+			}
+		})
+	}
+}
+
 func TestHealthEndpoint(t *testing.T) {
 	// Create test mapping list
 	mappingList := tmconfig.MappingList{