Added endpoints for config based mapping on responses and queries

Change-Id: I981233dc5b8915e18759fca398a4359194088d86
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index a998977..0e5044b 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -268,6 +268,10 @@
 		return c.SendString("OK")
 	})
 
+	// Composite cascade transformation endpoints
+	app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
+	app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
+
 	// Transformation endpoint
 	app.Post("/:map/query", handleTransform(m))
 
@@ -279,6 +283,120 @@
 	app.Get("/:map", handleKalamarPlugin(yamlConfig))
 }
 
+func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		cfgRaw := c.Query("cfg", "")
+		if len(cfgRaw) > maxParamLength {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
+			})
+		}
+
+		var jsonData any
+		if err := c.BodyParser(&jsonData); err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": "invalid JSON in request body",
+			})
+		}
+
+		entries, err := ParseCfgParam(cfgRaw, lists)
+		if err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		if len(entries) == 0 {
+			return c.JSON(jsonData)
+		}
+
+		orderedIDs := make([]string, 0, len(entries))
+		opts := make([]mapper.MappingOptions, 0, len(entries))
+		for _, entry := range entries {
+			dir := mapper.AtoB
+			if entry.Direction == "btoa" {
+				dir = mapper.BtoA
+			}
+
+			orderedIDs = append(orderedIDs, entry.ID)
+			opts = append(opts, mapper.MappingOptions{
+				Direction: dir,
+				FoundryA:  entry.FoundryA,
+				LayerA:    entry.LayerA,
+				FoundryB:  entry.FoundryB,
+				LayerB:    entry.LayerB,
+			})
+		}
+
+		result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
+		if err != nil {
+			log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		return c.JSON(result)
+	}
+}
+
+func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		cfgRaw := c.Query("cfg", "")
+		if len(cfgRaw) > maxParamLength {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
+			})
+		}
+
+		var jsonData any
+		if err := c.BodyParser(&jsonData); err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": "invalid JSON in request body",
+			})
+		}
+
+		entries, err := ParseCfgParam(cfgRaw, lists)
+		if err != nil {
+			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		if len(entries) == 0 {
+			return c.JSON(jsonData)
+		}
+
+		orderedIDs := make([]string, 0, len(entries))
+		opts := make([]mapper.MappingOptions, 0, len(entries))
+		for _, entry := range entries {
+			dir := mapper.AtoB
+			if entry.Direction == "btoa" {
+				dir = mapper.BtoA
+			}
+
+			orderedIDs = append(orderedIDs, entry.ID)
+			opts = append(opts, mapper.MappingOptions{
+				Direction: dir,
+				FoundryA:  entry.FoundryA,
+				LayerA:    entry.LayerA,
+				FoundryB:  entry.FoundryB,
+				LayerB:    entry.LayerB,
+			})
+		}
+
+		result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
+		if err != nil {
+			log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
+			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+				"error": err.Error(),
+			})
+		}
+
+		return c.JSON(result)
+	}
+}
+
 func handleTransform(m *mapper.Mapper) fiber.Handler {
 	return func(c *fiber.Ctx) error {
 		// Extract and validate parameters
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index fdd9612..a3e45a8 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -1548,3 +1548,222 @@
 		})
 	}
 }
+
+func TestCompositeQueryEndpoint(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:       "step1",
+			FoundryA: "opennlp",
+			LayerA:   "p",
+			FoundryB: "opennlp",
+			LayerB:   "p",
+			Mappings: []tmconfig.MappingRule{
+				"[PIDAT] <> [DET]",
+			},
+		},
+		{
+			ID:       "step2",
+			FoundryA: "opennlp",
+			LayerA:   "p",
+			FoundryB: "upos",
+			LayerB:   "p",
+			Mappings: []tmconfig.MappingRule{
+				"[DET] <> [PRON]",
+			},
+		},
+	}
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, &tmconfig.MappingConfig{Lists: lists})
+
+	tests := []struct {
+		name         string
+		url          string
+		input        string
+		expectedCode int
+		expected     any
+	}{
+		{
+			name:         "cascades two query mappings",
+			url:          "/query?cfg=step1:atob;step2:atob",
+			expectedCode: http.StatusOK,
+			input: `{
+				"@type": "koral:token",
+				"wrap": {
+					"@type": "koral:term",
+					"foundry": "opennlp",
+					"key": "PIDAT",
+					"layer": "p",
+					"match": "match:eq"
+				}
+			}`,
+			expected: map[string]any{
+				"@type": "koral:token",
+				"wrap": map[string]any{
+					"@type":   "koral:term",
+					"foundry": "upos",
+					"key":     "PRON",
+					"layer":   "p",
+					"match":   "match:eq",
+				},
+			},
+		},
+		{
+			name:         "empty cfg returns input unchanged",
+			url:          "/query?cfg=",
+			expectedCode: http.StatusOK,
+			input: `{
+				"@type": "koral:token",
+				"wrap": {
+					"@type": "koral:term",
+					"foundry": "opennlp",
+					"key": "PIDAT",
+					"layer": "p",
+					"match": "match:eq"
+				}
+			}`,
+			expected: map[string]any{
+				"@type": "koral:token",
+				"wrap": map[string]any{
+					"@type":   "koral:term",
+					"foundry": "opennlp",
+					"key":     "PIDAT",
+					"layer":   "p",
+					"match":   "match:eq",
+				},
+			},
+		},
+		{
+			name:         "invalid cfg returns bad request",
+			url:          "/query?cfg=missing:atob",
+			expectedCode: http.StatusBadRequest,
+			input:        `{"@type": "koral:token"}`,
+			expected: map[string]any{
+				"error": `unknown mapping ID "missing"`,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, tt.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()
+
+			assert.Equal(t, tt.expectedCode, resp.StatusCode)
+
+			body, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+
+			var actual any
+			err = json.Unmarshal(body, &actual)
+			require.NoError(t, err)
+			assert.Equal(t, tt.expected, actual)
+		})
+	}
+}
+
+func TestCompositeResponseEndpoint(t *testing.T) {
+	lists := []tmconfig.MappingList{
+		{
+			ID:       "resp-step1",
+			Type:     "corpus",
+			Mappings: []tmconfig.MappingRule{"textClass=novel <> genre=fiction"},
+		},
+		{
+			ID:       "resp-step2",
+			Type:     "corpus",
+			Mappings: []tmconfig.MappingRule{"genre=fiction <> category=lit"},
+		},
+	}
+	m, err := mapper.NewMapper(lists)
+	require.NoError(t, err)
+
+	app := fiber.New()
+	setupRoutes(app, m, &tmconfig.MappingConfig{Lists: lists})
+
+	tests := []struct {
+		name         string
+		url          string
+		input        string
+		expectedCode int
+		assertBody   func(t *testing.T, actual map[string]any)
+	}{
+		{
+			name:         "cascades two response mappings",
+			url:          "/response?cfg=resp-step1:atob;resp-step2:atob",
+			expectedCode: http.StatusOK,
+			input: `{
+				"fields": [{
+					"@type": "koral:field",
+					"key": "textClass",
+					"value": "novel",
+					"type": "type:string"
+				}]
+			}`,
+			assertBody: func(t *testing.T, actual map[string]any) {
+				fields := actual["fields"].([]any)
+				require.Len(t, fields, 3)
+				assert.Equal(t, "textClass", fields[0].(map[string]any)["key"])
+				assert.Equal(t, "genre", fields[1].(map[string]any)["key"])
+				assert.Equal(t, "fiction", fields[1].(map[string]any)["value"])
+				assert.Equal(t, "category", fields[2].(map[string]any)["key"])
+				assert.Equal(t, "lit", fields[2].(map[string]any)["value"])
+			},
+		},
+		{
+			name:         "empty cfg returns input unchanged",
+			url:          "/response?cfg=",
+			expectedCode: http.StatusOK,
+			input: `{
+				"fields": [{
+					"@type": "koral:field",
+					"key": "textClass",
+					"value": "novel",
+					"type": "type:string"
+				}]
+			}`,
+			assertBody: func(t *testing.T, actual map[string]any) {
+				fields := actual["fields"].([]any)
+				require.Len(t, fields, 1)
+				assert.Equal(t, "textClass", fields[0].(map[string]any)["key"])
+				assert.Equal(t, "novel", fields[0].(map[string]any)["value"])
+			},
+		},
+		{
+			name:         "invalid cfg returns bad request",
+			url:          "/response?cfg=resp-step1",
+			expectedCode: http.StatusBadRequest,
+			input:        `{"fields": []}`,
+			assertBody: func(t *testing.T, actual map[string]any) {
+				assert.Contains(t, actual["error"], "expected 2 or 6 colon-separated fields")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, tt.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()
+
+			assert.Equal(t, tt.expectedCode, resp.StatusCode)
+
+			body, err := io.ReadAll(resp.Body)
+			require.NoError(t, err)
+
+			var actual map[string]any
+			err = json.Unmarshal(body, &actual)
+			require.NoError(t, err)
+			tt.assertBody(t, actual)
+		})
+	}
+}