Added web server for transformations
diff --git a/cmd/termmapper/main.go b/cmd/termmapper/main.go
new file mode 100644
index 0000000..aa4e3e2
--- /dev/null
+++ b/cmd/termmapper/main.go
@@ -0,0 +1,164 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+
+ "github.com/KorAP/KoralPipe-TermMapper2/pkg/mapper"
+ "github.com/gofiber/fiber/v2"
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+type config struct {
+ port int
+ config string
+ logLevel string
+}
+
+func parseFlags() *config {
+ cfg := &config{}
+
+ flag.IntVar(&cfg.port, "port", 8080, "Port to listen on")
+ flag.IntVar(&cfg.port, "p", 8080, "Port to listen on (shorthand)")
+
+ flag.StringVar(&cfg.config, "config", "", "YAML configuration file containing mapping directives")
+ flag.StringVar(&cfg.config, "c", "", "YAML configuration file containing mapping directives (shorthand)")
+
+ flag.StringVar(&cfg.logLevel, "log-level", "info", "Log level (debug, info, warn, error)")
+ flag.StringVar(&cfg.logLevel, "l", "info", "Log level (shorthand)")
+
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
+ fmt.Fprintf(os.Stderr, "\nA web service for transforming JSON objects using term mapping rules.\n\n")
+ fmt.Fprintf(os.Stderr, "Options:\n")
+ flag.PrintDefaults()
+ }
+
+ flag.Parse()
+
+ if cfg.config == "" {
+ fmt.Fprintln(os.Stderr, "Error: config file is required")
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ return cfg
+}
+
+func setupLogger(level string) {
+ // Parse log level
+ lvl, err := zerolog.ParseLevel(strings.ToLower(level))
+ if err != nil {
+ log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
+ lvl = zerolog.InfoLevel
+ }
+
+ // Configure zerolog
+ zerolog.SetGlobalLevel(lvl)
+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
+}
+
+func main() {
+ // Parse command line flags
+ cfg := parseFlags()
+
+ // Set up logging
+ setupLogger(cfg.logLevel)
+
+ // Create a new mapper instance
+ m, err := mapper.NewMapper(cfg.config)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Failed to create mapper")
+ }
+
+ // Create fiber app
+ app := fiber.New(fiber.Config{
+ DisableStartupMessage: true,
+ })
+
+ // Set up routes
+ setupRoutes(app, m)
+
+ // Start server
+ go func() {
+ log.Info().Int("port", cfg.port).Msg("Starting server")
+ if err := app.Listen(fmt.Sprintf(":%d", cfg.port)); err != nil {
+ log.Fatal().Err(err).Msg("Server error")
+ }
+ }()
+
+ // Wait for interrupt signal
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+ <-sigChan
+
+ // Graceful shutdown
+ log.Info().Msg("Shutting down server")
+ if err := app.Shutdown(); err != nil {
+ log.Error().Err(err).Msg("Error during shutdown")
+ }
+}
+
+func setupRoutes(app *fiber.App, m *mapper.Mapper) {
+ // Health check endpoint
+ app.Get("/health", func(c *fiber.Ctx) error {
+ return c.SendString("OK")
+ })
+
+ // Transformation endpoint
+ app.Post("/:map/query", handleTransform(m))
+}
+
+func handleTransform(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 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 interface{}
+ if err := c.BodyParser(&jsonData); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "invalid JSON in request body",
+ })
+ }
+
+ // Apply mappings
+ result, err := m.ApplyMappings(mapID, mapper.MappingOptions{
+ Direction: mapper.Direction(dir),
+ 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 mappings")
+
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ return c.JSON(result)
+ }
+}
diff --git a/cmd/termmapper/main_test.go b/cmd/termmapper/main_test.go
new file mode 100644
index 0000000..0387cc6
--- /dev/null
+++ b/cmd/termmapper/main_test.go
@@ -0,0 +1,286 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/KorAP/KoralPipe-TermMapper2/pkg/mapper"
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTransformEndpoint(t *testing.T) {
+ // Create a temporary config file
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "test-config.yaml")
+
+ configContent := `- id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ mappings:
+ - "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]"
+ - "[DET] <> [opennlp/p=DET]"`
+
+ err := os.WriteFile(configFile, []byte(configContent), 0644)
+ require.NoError(t, err)
+
+ // Create mapper
+ m, err := mapper.NewMapper(configFile)
+ require.NoError(t, err)
+
+ // Create fiber app
+ app := fiber.New()
+ setupRoutes(app, m)
+
+ 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 A to B mapping",
+ mapID: "test-mapper",
+ direction: "atob",
+ input: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`,
+ expectedCode: http.StatusOK,
+ expectedBody: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:termGroup",
+ "operands": [
+ {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ },
+ {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "AdjType",
+ "layer": "p",
+ "match": "match:eq",
+ "value": "Pdt"
+ }
+ ],
+ "relation": "relation:and"
+ }
+ }`,
+ },
+ {
+ name: "B to A mapping",
+ mapID: "test-mapper",
+ direction: "btoa",
+ input: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:termGroup",
+ "operands": [
+ {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ },
+ {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "AdjType",
+ "layer": "p",
+ "match": "match:eq",
+ "value": "Pdt"
+ }
+ ],
+ "relation": "relation:and"
+ }
+ }`,
+ expectedCode: http.StatusOK,
+ expectedBody: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`,
+ },
+ {
+ name: "Mapping with foundry override",
+ mapID: "test-mapper",
+ direction: "atob",
+ foundryB: "custom",
+ input: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`,
+ expectedCode: http.StatusOK,
+ expectedBody: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:termGroup",
+ "operands": [
+ {
+ "@type": "koral:term",
+ "foundry": "custom",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ },
+ {
+ "@type": "koral:term",
+ "foundry": "custom",
+ "key": "AdjType",
+ "layer": "p",
+ "match": "match:eq",
+ "value": "Pdt"
+ }
+ ],
+ "relation": "relation:and"
+ }
+ }`,
+ },
+ {
+ name: "Invalid mapping ID",
+ mapID: "nonexistent",
+ direction: "atob",
+ input: `{"@type": "koral:token"}`,
+ expectedCode: http.StatusInternalServerError,
+ expectedError: "mapping list with ID nonexistent not found",
+ },
+ {
+ name: "Invalid direction",
+ mapID: "test-mapper",
+ direction: "invalid",
+ input: `{"@type": "koral:token"}`,
+ expectedCode: http.StatusBadRequest,
+ expectedError: "invalid direction, must be 'atob' or 'btoa'",
+ },
+ {
+ name: "Invalid JSON",
+ mapID: "test-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 + "/query"
+ 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 interface{}
+ 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 a temporary config file for the mapper
+ tmpDir := t.TempDir()
+ configFile := filepath.Join(tmpDir, "test-config.yaml")
+ configContent := `- id: test-mapper
+ mappings:
+ - "[A] <> [B]"`
+
+ err := os.WriteFile(configFile, []byte(configContent), 0644)
+ require.NoError(t, err)
+
+ // Create mapper with config
+ m, err := mapper.NewMapper(configFile)
+ require.NoError(t, err)
+
+ // Create fiber app
+ app := fiber.New()
+ setupRoutes(app, m)
+
+ // Test health endpoint
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Equal(t, "OK", string(body))
+}