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{