Rename KoralPipe-TermMapper to Koral-Mapper
Change-Id: Ib71a02b6640a9d93d81cb419d760f13a60ec5f58
diff --git a/cmd/koralmapper/fuzz_test.go b/cmd/koralmapper/fuzz_test.go
new file mode 100644
index 0000000..39f20c3
--- /dev/null
+++ b/cmd/koralmapper/fuzz_test.go
@@ -0,0 +1,420 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ tmconfig "github.com/KorAP/Koral-Mapper/config"
+ "github.com/KorAP/Koral-Mapper/mapper"
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// FuzzInput represents the input data for the fuzzer
+type FuzzInput struct {
+ MapID string
+ Direction string
+ FoundryA string
+ FoundryB string
+ LayerA string
+ LayerB string
+ Body []byte
+}
+
+func FuzzTransformEndpoint(f *testing.F) {
+ // Create test mapping list
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ FoundryA: "opennlp",
+ LayerA: "p",
+ FoundryB: "upos",
+ LayerB: "p",
+ Mappings: []tmconfig.MappingRule{
+ "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]",
+ "[DET] <> [opennlp/p=DET]",
+ },
+ }
+
+ // 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(`{"@type": "koral:token"}`)) // Valid minimal input
+ f.Add("test-mapper", "btoa", "custom", "", "", "", []byte(`{"@type": "koral:token"}`)) // Valid with foundry override
+ f.Add("", "", "", "", "", "", []byte(`{}`)) // Empty parameters
+ f.Add("nonexistent", "invalid", "!@#$", "%^&*", "()", "[]", []byte(`invalid json`)) // Invalid everything
+ f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": null}`)) // Valid JSON, invalid structure
+ f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": {"@type": "unknown"}}`)) // Unknown type
+ f.Add("test-mapper", "atob", "", "", "", "", []byte(`{"@type": "koral:token", "wrap": {"@type": "koral:term"}}`)) // Missing required fields
+ f.Add("0", "0", strings.Repeat("\x83", 1000), "0", "Q", "", []byte("0")) // Failing fuzz test case
+
+ 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/query", 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 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{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{
+ "[A] <> [B]",
+ },
+ }
+
+ // 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(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)
+
+ tests := []struct {
+ name string
+ mapID string
+ direction string
+ foundryA string
+ foundryB string
+ layerA string
+ layerB string
+ input string
+ expectedCode int
+ expectedError string
+ }{
+ {
+ name: "Large map ID",
+ mapID: strings.Repeat("a", maxParamLength+1),
+ direction: "atob",
+ input: "{}",
+ expectedCode: http.StatusBadRequest,
+ expectedError: "mapID too long (max 1024 bytes)",
+ },
+ {
+ name: "Large direction",
+ mapID: "test-mapper",
+ direction: strings.Repeat("a", maxParamLength+1),
+ input: "{}",
+ expectedCode: http.StatusBadRequest,
+ expectedError: "dir too long (max 1024 bytes)",
+ },
+ {
+ name: "Large foundryA",
+ mapID: "test-mapper",
+ direction: "atob",
+ foundryA: strings.Repeat("a", maxParamLength+1),
+ input: "{}",
+ expectedCode: http.StatusBadRequest,
+ expectedError: "foundryA too long (max 1024 bytes)",
+ },
+ {
+ name: "Invalid characters in mapID",
+ mapID: "test<>mapper",
+ direction: "atob",
+ input: "{}",
+ expectedCode: http.StatusBadRequest,
+ expectedError: "mapID contains invalid characters",
+ },
+ {
+ name: "Large request body",
+ mapID: "test-mapper",
+ direction: "atob",
+ input: strings.Repeat("a", maxInputLength+1),
+ expectedCode: http.StatusRequestEntityTooLarge,
+ expectedError: "body size exceeds the given limit",
+ },
+ }
+
+ 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, strings.NewReader(tt.input))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+
+ if resp == nil {
+ assert.Equal(t, tt.expectedError, err.Error())
+ return
+ }
+
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ // Check status code
+ assert.Equal(t, tt.expectedCode, resp.StatusCode)
+
+ // Check error message
+ var result map[string]any
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+ errMsg, ok := result["error"].(string)
+ require.True(t, ok)
+ assert.Equal(t, tt.expectedError, errMsg)
+ })
+ }
+}
+
+// # Run fuzzing for 1 minute
+// go test -fuzz=FuzzTransformEndpoint -fuzztime=1m ./cmd/koralmapper
+//
+// # Run fuzzing until a crash is found or Ctrl+C is pressed
+// go test -fuzz=FuzzTransformEndpoint ./cmd/koralmapper
+//
+// # Run fuzzing with verbose output
+// go test -fuzz=FuzzTransformEndpoint -v ./cmd/koralmapper
+//
+// go test -run=FuzzTransformEndpoint/testdata/fuzz/FuzzTransformEndpoint/$SEED
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
new file mode 100644
index 0000000..a998977
--- /dev/null
+++ b/cmd/koralmapper/main.go
@@ -0,0 +1,629 @@
+package main
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "os/signal"
+ "path"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/KorAP/Koral-Mapper/config"
+ "github.com/KorAP/Koral-Mapper/mapper"
+ "github.com/alecthomas/kong"
+ "github.com/gofiber/fiber/v2"
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+const (
+ maxInputLength = 1024 * 1024 // 1MB
+ maxParamLength = 1024 // 1KB
+)
+
+type appConfig struct {
+ Port *int `kong:"short='p',help='Port to listen on'"`
+ Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
+ Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
+ LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
+}
+
+type TemplateMapping struct {
+ ID string
+ Description string
+}
+
+// TemplateData holds data for the Kalamar plugin template
+type TemplateData struct {
+ Title string
+ Version string
+ Hash string
+ Date string
+ Description string
+ Server string
+ SDK string
+ ServiceURL string
+ MapID string
+ Mappings []TemplateMapping
+}
+
+type QueryParams struct {
+ Dir string
+ FoundryA string
+ FoundryB string
+ LayerA string
+ LayerB string
+}
+
+// requestParams holds common request parameters
+type requestParams struct {
+ MapID string
+ Dir string
+ FoundryA string
+ FoundryB string
+ LayerA string
+ LayerB string
+}
+
+func parseConfig() *appConfig {
+ cfg := &appConfig{}
+
+ desc := config.Description
+ desc += " [" + config.Version + "]"
+
+ ctx := kong.Parse(cfg,
+ kong.Description(desc),
+ kong.UsageOnError(),
+ )
+ if ctx.Error != nil {
+ fmt.Fprintln(os.Stderr, ctx.Error)
+ 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})
+}
+
+// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
+func setupFiberLogger() fiber.Handler {
+ // Check if HTTP request logging should be enabled based on current log level
+ currentLevel := zerolog.GlobalLevel()
+
+ // Only enable HTTP request logging if log level is debug or info
+ if currentLevel > zerolog.InfoLevel {
+ return func(c *fiber.Ctx) error {
+ return c.Next()
+ }
+ }
+
+ return func(c *fiber.Ctx) error {
+ // Record start time
+ start := time.Now()
+
+ // Process request
+ err := c.Next()
+
+ // Calculate latency
+ latency := time.Since(start)
+ status := c.Response().StatusCode()
+
+ // Determine log level based on status code
+ logEvent := log.Info()
+ if status >= 400 && status < 500 {
+ logEvent = log.Warn()
+ } else if status >= 500 {
+ logEvent = log.Error()
+ }
+
+ // Log the request
+ logEvent.
+ Int("status", status).
+ Dur("latency", latency).
+ Str("method", c.Method()).
+ Str("path", c.Path()).
+ Str("ip", c.IP()).
+ Str("user_agent", c.Get("User-Agent")).
+ Msg("HTTP request")
+
+ return err
+ }
+}
+
+// extractRequestParams extracts and validates common request parameters
+func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
+ params := &requestParams{
+ 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(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
+ return nil, err
+ }
+
+ // Validate direction
+ if params.Dir != "atob" && params.Dir != "btoa" {
+ return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
+ }
+
+ return params, nil
+}
+
+// parseRequestBody parses JSON request body and direction
+func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
+ var jsonData any
+ if err := c.BodyParser(&jsonData); err != nil {
+ return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
+ }
+
+ direction, err := mapper.ParseDirection(dir)
+ if err != nil {
+ return nil, mapper.BtoA, err
+ }
+
+ return jsonData, direction, nil
+}
+
+func main() {
+ // Parse command line flags
+ cfg := parseConfig()
+
+ // Validate command line arguments
+ if cfg.Config == "" && len(cfg.Mappings) == 0 {
+ log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
+ }
+
+ // Expand glob patterns in mapping files
+ expandedMappings, err := expandGlobs(cfg.Mappings)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
+ }
+
+ // Load configuration from multiple sources
+ yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Failed to load configuration")
+ }
+
+ finalPort := yamlConfig.Port
+ finalLogLevel := yamlConfig.LogLevel
+
+ // Use command line values if provided (they override config file)
+ if cfg.Port != nil {
+ finalPort = *cfg.Port
+ }
+ if cfg.LogLevel != nil {
+ finalLogLevel = *cfg.LogLevel
+ }
+
+ // Set up logging with the final log level
+ setupLogger(finalLogLevel)
+
+ // Create a new mapper instance
+ m, err := mapper.NewMapper(yamlConfig.Lists)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Failed to create mapper")
+ }
+
+ // Create fiber app
+ app := fiber.New(fiber.Config{
+ DisableStartupMessage: true,
+ BodyLimit: maxInputLength,
+ ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
+ WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
+ })
+
+ // Add zerolog-integrated logger middleware
+ app.Use(setupFiberLogger())
+
+ // Set up routes
+ setupRoutes(app, m, yamlConfig)
+
+ // Start server
+ go func() {
+ log.Info().Int("port", finalPort).Msg("Starting server")
+
+ for _, list := range yamlConfig.Lists {
+ log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
+ }
+
+ if err := app.Listen(fmt.Sprintf(":%d", finalPort)); 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, yamlConfig *config.MappingConfig) {
+ // Health check endpoint
+ app.Get("/health", func(c *fiber.Ctx) error {
+ return c.SendString("OK")
+ })
+
+ // 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))
+}
+
+func handleTransform(m *mapper.Mapper) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ // Extract and validate parameters
+ params, err := extractRequestParams(c)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ // Parse request body
+ jsonData, direction, err := parseRequestBody(c, params.Dir)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ // Apply mappings
+ result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
+ Direction: direction,
+ FoundryA: params.FoundryA,
+ FoundryB: params.FoundryB,
+ LayerA: params.LayerA,
+ LayerB: params.LayerB,
+ }, jsonData)
+
+ if err != nil {
+ log.Error().Err(err).
+ Str("mapID", params.MapID).
+ Str("direction", params.Dir).
+ Msg("Failed to apply mappings")
+
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ return c.JSON(result)
+ }
+}
+
+func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ // Extract and validate parameters
+ params, err := extractRequestParams(c)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ // Parse request body
+ jsonData, direction, err := parseRequestBody(c, params.Dir)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ // Apply response mappings
+ result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
+ Direction: direction,
+ FoundryA: params.FoundryA,
+ FoundryB: params.FoundryB,
+ LayerA: params.LayerA,
+ LayerB: params.LayerB,
+ }, jsonData)
+
+ if err != nil {
+ log.Error().Err(err).
+ Str("mapID", params.MapID).
+ Str("direction", params.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
+ params := []struct {
+ name string
+ value string
+ }{
+ {"mapID", mapID},
+ {"dir", dir},
+ {"foundryA", foundryA},
+ {"foundryB", foundryB},
+ {"layerA", layerA},
+ {"layerB", layerB},
+ }
+
+ for _, param := range params {
+ // Check input lengths and invalid characters in one combined condition
+ if len(param.value) > maxParamLength {
+ return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
+ }
+ if strings.ContainsAny(param.value, "<>{}[]\\") {
+ return fmt.Errorf("%s contains invalid characters", param.name)
+ }
+ }
+
+ if len(body) > maxInputLength {
+ return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
+ }
+
+ return nil
+}
+
+func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ mapID := c.Params("map")
+
+ // Get query parameters
+ dir := c.Query("dir", "atob")
+ foundryA := c.Query("foundryA", "")
+ foundryB := c.Query("foundryB", "")
+ layerA := c.Query("layerA", "")
+ layerB := c.Query("layerB", "")
+
+ // Validate input parameters and direction in one step
+ if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ if dir != "atob" && dir != "btoa" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "invalid direction, must be 'atob' or 'btoa'",
+ })
+ }
+
+ // Get list of available mappings
+ var mappings []TemplateMapping
+ for _, list := range yamlConfig.Lists {
+ mappings = append(mappings, TemplateMapping{
+ ID: list.ID,
+ Description: list.Description,
+ })
+ }
+
+ // Prepare template data
+ data := TemplateData{
+ Title: config.Title,
+ Version: config.Version,
+ Hash: config.Buildhash,
+ Date: config.Buildtime,
+ Description: config.Description,
+ Server: yamlConfig.Server,
+ SDK: yamlConfig.SDK,
+ ServiceURL: yamlConfig.ServiceURL,
+ MapID: mapID,
+ Mappings: mappings,
+ }
+
+ // Add query parameters to template data
+ queryParams := QueryParams{
+ Dir: dir,
+ FoundryA: foundryA,
+ FoundryB: foundryB,
+ LayerA: layerA,
+ LayerB: layerB,
+ }
+
+ // Generate HTML
+ html := generateKalamarPluginHTML(data, queryParams)
+
+ c.Set("Content-Type", "text/html")
+ return c.SendString(html)
+ }
+}
+
+// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
+// This function can be easily modified to change the appearance and content
+func generateKalamarPluginHTML(data TemplateData, queryParams QueryParams) string {
+ html := `<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>` + data.Title + `</title>
+ <script src="` + data.SDK + `"
+ data-server="` + data.Server + `"></script>
+</head>
+<body>
+ <div class="container">
+ <h1>` + data.Title + `</h1>
+ <p>` + data.Description + `</p>`
+
+ if data.MapID != "" {
+ html += `<p>Map ID: ` + data.MapID + `</p>`
+ }
+
+ html += ` <h2>Plugin Information</h2>
+ <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
+ <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
+ <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
+
+ <h2>Available API Endpoints</h2>
+ <dl>
+
+ <dt><tt><strong>GET</strong> /:map</tt></dt>
+ <dd><small>Kalamar integration</small></dd>
+
+ <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>
+
+ <h2>Available Term Mappings</h2>
+ <dl>`
+
+ for _, m := range data.Mappings {
+ html += `<dt><tt>` + m.ID + `</tt></dt>`
+ html += `<dd>` + m.Description + `</dd>`
+ }
+
+ html += `
+ </dl></div>`
+
+ if data.MapID != "" {
+
+ queryServiceURL, err := url.Parse(data.ServiceURL)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to join URL path")
+ }
+
+ // Use path.Join to normalize the path part
+ queryServiceURL.Path = path.Join(queryServiceURL.Path, data.MapID+"/query")
+
+ // Build query parameters for query URL
+ queryParamString := buildQueryParams(queryParams.Dir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
+ queryServiceURL.RawQuery = queryParamString
+
+ responseServiceURL, err := url.Parse(data.ServiceURL)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to join URL path")
+ }
+
+ // Use path.Join to normalize the path part
+ responseServiceURL.Path = path.Join(responseServiceURL.Path, data.MapID+"/response")
+
+ reversedDir := "btoa"
+ if queryParams.Dir == "btoa" {
+ reversedDir = "atob"
+ }
+
+ // Build query parameters for response URL (with reversed direction)
+ responseParamString := buildQueryParams(reversedDir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
+ responseServiceURL.RawQuery = responseParamString
+
+ html += `<script>
+ <!-- activates/deactivates Mapper. -->
+
+ let qdata = {
+ 'action' : 'pipe',
+ 'service' : '` + queryServiceURL.String() + `'
+ };
+
+ let rdata = {
+ 'action' : 'pipe',
+ 'service' : '` + responseServiceURL.String() + `'
+ };
+
+
+ function pluginit (p) {
+ p.onMessage = function(msg) {
+ if (msg.key == 'koralmapper') {
+ if (msg.value) {
+ qdata['job'] = 'add';
+ }
+ else {
+ qdata['job'] = 'del';
+ };
+ KorAPlugin.sendMsg(qdata);
+ if (msg.value) {
+ rdata['job'] = 'add-after';
+ }
+ else {
+ rdata['job'] = 'del-after';
+ };
+ KorAPlugin.sendMsg(rdata);
+ };
+ };
+ };
+ </script>`
+ }
+
+ html += ` </body>
+</html>`
+
+ return html
+}
+
+// buildQueryParams builds a query string from the provided parameters
+func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
+ params := url.Values{}
+ if dir != "" {
+ params.Add("dir", dir)
+ }
+ if foundryA != "" {
+ params.Add("foundryA", foundryA)
+ }
+ if foundryB != "" {
+ params.Add("foundryB", foundryB)
+ }
+ if layerA != "" {
+ params.Add("layerA", layerA)
+ }
+ if layerB != "" {
+ params.Add("layerB", layerB)
+ }
+ return params.Encode()
+}
+
+// expandGlobs expands glob patterns in the slice of file paths
+// Returns the expanded list of files or an error if glob expansion fails
+func expandGlobs(patterns []string) ([]string, error) {
+ var expanded []string
+
+ for _, pattern := range patterns {
+ // Use filepath.Glob which works cross-platform
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
+ }
+
+ // If no matches found, treat as literal filename (consistent with shell behavior)
+ if len(matches) == 0 {
+ log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
+ expanded = append(expanded, pattern)
+ } else {
+ expanded = append(expanded, matches...)
+ }
+ }
+
+ return expanded, nil
+}
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
new file mode 100644
index 0000000..fdd9612
--- /dev/null
+++ b/cmd/koralmapper/main_test.go
@@ -0,0 +1,1550 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+
+ tmconfig "github.com/KorAP/Koral-Mapper/config"
+ "github.com/KorAP/Koral-Mapper/mapper"
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTransformEndpoint(t *testing.T) {
+ // Create test mapping list
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ FoundryA: "opennlp",
+ LayerA: "p",
+ FoundryB: "upos",
+ LayerB: "p",
+ Mappings: []tmconfig.MappingRule{
+ "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]",
+ "[DET] <> [opennlp/p=DET]",
+ },
+ }
+
+ // 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 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 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 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{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{
+ "[A] <> [B]",
+ },
+ }
+
+ // 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)
+
+ // 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))
+
+ req = httptest.NewRequest(http.MethodGet, "/", 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.Contains(t, string(body), "Koral-Mapper")
+
+}
+
+func TestKalamarPluginWithCustomSdkAndServer(t *testing.T) {
+ // Create test mapping list
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{
+ "[A] <> [B]",
+ },
+ }
+
+ // Create mapper
+ m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ customSDK string
+ customServer string
+ expectedSDK string
+ expectedServer string
+ }{
+ {
+ name: "Custom SDK and Server values",
+ customSDK: "https://custom.example.com/custom-sdk.js",
+ customServer: "https://custom.example.com/",
+ expectedSDK: "https://custom.example.com/custom-sdk.js",
+ expectedServer: "https://custom.example.com/",
+ },
+ {
+ name: "Only custom SDK value",
+ customSDK: "https://custom.example.com/custom-sdk.js",
+ customServer: "https://korap.ids-mannheim.de/", // defaults applied during parsing
+ expectedSDK: "https://custom.example.com/custom-sdk.js",
+ expectedServer: "https://korap.ids-mannheim.de/",
+ },
+ {
+ name: "Only custom Server value",
+ customSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // defaults applied during parsing
+ customServer: "https://custom.example.com/",
+ expectedSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js",
+ expectedServer: "https://custom.example.com/",
+ },
+ {
+ name: "Defaults applied during parsing",
+ customSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // defaults applied during parsing
+ customServer: "https://korap.ids-mannheim.de/", // defaults applied during parsing
+ expectedSDK: "https://korap.ids-mannheim.de/js/korap-plugin-latest.js",
+ expectedServer: "https://korap.ids-mannheim.de/",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create mock config with custom values
+ mockConfig := &tmconfig.MappingConfig{
+ SDK: tt.customSDK,
+ Server: tt.customServer,
+ Lists: []tmconfig.MappingList{mappingList},
+ }
+
+ // Create fiber app
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ // Test Kalamar plugin endpoint
+ req := httptest.NewRequest(http.MethodGet, "/", 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)
+
+ htmlContent := string(body)
+
+ // Check that the HTML contains the expected SDK and Server values
+ assert.Contains(t, htmlContent, `src="`+tt.expectedSDK+`"`)
+ assert.Contains(t, htmlContent, `data-server="`+tt.expectedServer+`"`)
+
+ // Ensure it's still a valid HTML page
+ assert.Contains(t, htmlContent, "Koral-Mapper")
+ assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+ })
+ }
+}
+
+func TestMultipleMappingFiles(t *testing.T) {
+ // Create test mapping files
+ mappingFile1Content := `
+id: test-mapper-1
+foundryA: opennlp
+layerA: p
+foundryB: upos
+layerB: p
+mappings:
+ - "[PIDAT] <> [DET & AdjType=Pdt]"
+ - "[PAV] <> [ADV & PronType=Dem]"
+`
+ mappingFile1, err := os.CreateTemp("", "mapping1-*.yaml")
+ require.NoError(t, err)
+ defer os.Remove(mappingFile1.Name())
+
+ _, err = mappingFile1.WriteString(mappingFile1Content)
+ require.NoError(t, err)
+ err = mappingFile1.Close()
+ require.NoError(t, err)
+
+ mappingFile2Content := `
+id: test-mapper-2
+foundryA: stts
+layerA: p
+foundryB: upos
+layerB: p
+mappings:
+ - "[DET] <> [PRON]"
+ - "[ADJ] <> [NOUN]"
+`
+ mappingFile2, err := os.CreateTemp("", "mapping2-*.yaml")
+ require.NoError(t, err)
+ defer os.Remove(mappingFile2.Name())
+
+ _, err = mappingFile2.WriteString(mappingFile2Content)
+ require.NoError(t, err)
+ err = mappingFile2.Close()
+ require.NoError(t, err)
+
+ // Load configuration using multiple mapping files
+ config, err := tmconfig.LoadFromSources("", []string{mappingFile1.Name(), mappingFile2.Name()})
+ require.NoError(t, err)
+
+ // Create mapper
+ m, err := mapper.NewMapper(config.Lists)
+ require.NoError(t, err)
+
+ // Create fiber app
+ app := fiber.New()
+ setupRoutes(app, m, config)
+
+ // Test that both mappers work
+ testCases := []struct {
+ name string
+ mapID string
+ input string
+ expectGroup bool
+ expectedKey string
+ }{
+ {
+ name: "test-mapper-1 with complex mapping",
+ mapID: "test-mapper-1",
+ input: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`,
+ expectGroup: true, // This mapping creates a termGroup because of "&"
+ expectedKey: "DET", // The first operand should be DET
+ },
+ {
+ name: "test-mapper-2 with simple mapping",
+ mapID: "test-mapper-2",
+ input: `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "stts",
+ "key": "DET",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`,
+ expectGroup: false, // This mapping creates a simple term
+ expectedKey: "PRON",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/"+tc.mapID+"/query?dir=atob", bytes.NewBufferString(tc.input))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+
+ // Check that the mapping was applied
+ wrap := result["wrap"].(map[string]interface{})
+ if tc.expectGroup {
+ // For complex mappings, check the first operand
+ assert.Equal(t, "koral:termGroup", wrap["@type"])
+ operands := wrap["operands"].([]interface{})
+ require.Greater(t, len(operands), 0)
+ firstOperand := operands[0].(map[string]interface{})
+ assert.Equal(t, tc.expectedKey, firstOperand["key"])
+ } else {
+ // For simple mappings, check the key directly
+ assert.Equal(t, "koral:term", wrap["@type"])
+ assert.Equal(t, tc.expectedKey, wrap["key"])
+ }
+ })
+ }
+}
+
+func TestCombinedConfigAndMappingFiles(t *testing.T) {
+ // Create main config file
+ mainConfigContent := `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: main-mapper
+ foundryA: opennlp
+ layerA: p
+ mappings:
+ - "[A] <> [B]"
+`
+ mainConfigFile, err := os.CreateTemp("", "main-config-*.yaml")
+ require.NoError(t, err)
+ defer os.Remove(mainConfigFile.Name())
+
+ _, err = mainConfigFile.WriteString(mainConfigContent)
+ require.NoError(t, err)
+ err = mainConfigFile.Close()
+ require.NoError(t, err)
+
+ // Create individual mapping file
+ mappingFileContent := `
+id: additional-mapper
+foundryA: stts
+layerA: p
+mappings:
+ - "[C] <> [D]"
+`
+ mappingFile, err := os.CreateTemp("", "mapping-*.yaml")
+ require.NoError(t, err)
+ defer os.Remove(mappingFile.Name())
+
+ _, err = mappingFile.WriteString(mappingFileContent)
+ require.NoError(t, err)
+ err = mappingFile.Close()
+ require.NoError(t, err)
+
+ // Load configuration from both sources
+ config, err := tmconfig.LoadFromSources(mainConfigFile.Name(), []string{mappingFile.Name()})
+ require.NoError(t, err)
+
+ // Verify that both mappers are loaded
+ require.Len(t, config.Lists, 2)
+
+ ids := make([]string, len(config.Lists))
+ for i, list := range config.Lists {
+ ids[i] = list.ID
+ }
+ assert.Contains(t, ids, "main-mapper")
+ assert.Contains(t, ids, "additional-mapper")
+
+ // Verify custom SDK and server are preserved from main config
+ assert.Equal(t, "https://custom.example.com/sdk.js", config.SDK)
+ assert.Equal(t, "https://custom.example.com/", config.Server)
+
+ // Create mapper and test it works
+ m, err := mapper.NewMapper(config.Lists)
+ require.NoError(t, err)
+ require.NotNil(t, m)
+}
+
+func TestExpandGlobs(t *testing.T) {
+ // Create a temporary directory for test files
+ tempDir, err := os.MkdirTemp("", "glob_test_*")
+ require.NoError(t, err)
+ defer os.RemoveAll(tempDir)
+
+ // Create test files with .yaml and .yml extensions
+ testFiles := []struct {
+ name string
+ content string
+ }{
+ {
+ name: "mapper1.yaml",
+ content: `
+id: test-mapper-1
+mappings:
+ - "[A] <> [B]"
+`,
+ },
+ {
+ name: "mapper2.yml",
+ content: `
+id: test-mapper-2
+mappings:
+ - "[C] <> [D]"
+`,
+ },
+ {
+ name: "mapper3.yaml",
+ content: `
+id: test-mapper-3
+mappings:
+ - "[E] <> [F]"
+`,
+ },
+ {
+ name: "other.txt",
+ content: "not a yaml file",
+ },
+ }
+
+ for _, file := range testFiles {
+ filePath := filepath.Join(tempDir, file.name)
+ err := os.WriteFile(filePath, []byte(file.content), 0644)
+ require.NoError(t, err)
+ }
+
+ tests := []struct {
+ name string
+ patterns []string
+ expected []string
+ expectErr bool
+ }{
+ {
+ name: "Single literal file",
+ patterns: []string{filepath.Join(tempDir, "mapper1.yaml")},
+ expected: []string{filepath.Join(tempDir, "mapper1.yaml")},
+ },
+ {
+ name: "Multiple literal files",
+ patterns: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper2.yml")},
+ expected: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper2.yml")},
+ },
+ {
+ name: "Glob pattern for yaml files",
+ patterns: []string{filepath.Join(tempDir, "*.yaml")},
+ expected: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper3.yaml")},
+ },
+ {
+ name: "Glob pattern for yml files",
+ patterns: []string{filepath.Join(tempDir, "*.yml")},
+ expected: []string{filepath.Join(tempDir, "mapper2.yml")},
+ },
+ {
+ name: "Glob pattern for all yaml/yml files",
+ patterns: []string{filepath.Join(tempDir, "*.y*ml")},
+ expected: []string{
+ filepath.Join(tempDir, "mapper1.yaml"),
+ filepath.Join(tempDir, "mapper2.yml"),
+ filepath.Join(tempDir, "mapper3.yaml"),
+ },
+ },
+ {
+ name: "Mixed literal and glob",
+ patterns: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "*.yml")},
+ expected: []string{filepath.Join(tempDir, "mapper1.yaml"), filepath.Join(tempDir, "mapper2.yml")},
+ },
+ {
+ name: "No matches - treats as literal",
+ patterns: []string{filepath.Join(tempDir, "nonexistent*.yaml")},
+ expected: []string{filepath.Join(tempDir, "nonexistent*.yaml")},
+ },
+ {
+ name: "Invalid glob pattern",
+ patterns: []string{filepath.Join(tempDir, "[")},
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := expandGlobs(tt.patterns)
+
+ if tt.expectErr {
+ assert.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Sort both slices for comparison since glob results may not be in consistent order
+ sort.Strings(result)
+ sort.Strings(tt.expected)
+
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestGlobMappingFileLoading(t *testing.T) {
+ // Create a temporary directory for test files
+ tempDir, err := os.MkdirTemp("", "glob_mapping_test_*")
+ require.NoError(t, err)
+ defer os.RemoveAll(tempDir)
+
+ // Create test mapping files
+ testFiles := []struct {
+ name string
+ content string
+ }{
+ {
+ name: "pos-mapper.yaml",
+ content: `
+id: pos-mapper
+foundryA: opennlp
+layerA: p
+foundryB: upos
+layerB: p
+mappings:
+ - "[PIDAT] <> [DET]"
+ - "[ADJA] <> [ADJ]"
+`,
+ },
+ {
+ name: "ner-mapper.yml",
+ content: `
+id: ner-mapper
+foundryA: opennlp
+layerA: ner
+foundryB: upos
+layerB: ner
+mappings:
+ - "[PER] <> [PERSON]"
+ - "[LOC] <> [LOCATION]"
+`,
+ },
+ {
+ name: "special-mapper.yaml",
+ content: `
+id: special-mapper
+mappings:
+ - "[X] <> [Y]"
+`,
+ },
+ }
+
+ for _, file := range testFiles {
+ filePath := filepath.Join(tempDir, file.name)
+ err := os.WriteFile(filePath, []byte(file.content), 0644)
+ require.NoError(t, err)
+ }
+
+ tests := []struct {
+ name string
+ configFile string
+ mappingPattern string
+ expectedIDs []string
+ }{
+ {
+ name: "Load all yaml files",
+ mappingPattern: filepath.Join(tempDir, "*.yaml"),
+ expectedIDs: []string{"pos-mapper", "special-mapper"},
+ },
+ {
+ name: "Load all yml files",
+ mappingPattern: filepath.Join(tempDir, "*.yml"),
+ expectedIDs: []string{"ner-mapper"},
+ },
+ {
+ name: "Load all yaml/yml files",
+ mappingPattern: filepath.Join(tempDir, "*-mapper.y*ml"),
+ expectedIDs: []string{"pos-mapper", "ner-mapper", "special-mapper"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Expand the glob pattern
+ expanded, err := expandGlobs([]string{tt.mappingPattern})
+ require.NoError(t, err)
+
+ // Load configuration using the expanded file list
+ config, err := tmconfig.LoadFromSources(tt.configFile, expanded)
+ require.NoError(t, err)
+
+ // Verify that the expected mappers are loaded
+ require.Len(t, config.Lists, len(tt.expectedIDs))
+
+ actualIDs := make([]string, len(config.Lists))
+ for i, list := range config.Lists {
+ actualIDs[i] = list.ID
+ }
+
+ // Sort both slices for comparison
+ sort.Strings(actualIDs)
+ sort.Strings(tt.expectedIDs)
+ assert.Equal(t, tt.expectedIDs, actualIDs)
+
+ // Create mapper to ensure all loaded configs are valid
+ m, err := mapper.NewMapper(config.Lists)
+ require.NoError(t, err)
+ require.NotNil(t, m)
+ })
+ }
+}
+
+func TestGlobErrorHandling(t *testing.T) {
+ tests := []struct {
+ name string
+ patterns []string
+ expectErr bool
+ }{
+ {
+ name: "Empty patterns",
+ patterns: []string{},
+ expectErr: false, // Should return empty slice, no error
+ },
+ {
+ name: "Invalid glob pattern",
+ patterns: []string{"["},
+ expectErr: true,
+ },
+ {
+ name: "Valid and invalid mixed",
+ patterns: []string{"valid.yaml", "["},
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := expandGlobs(tt.patterns)
+
+ if tt.expectErr {
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ } else {
+ assert.NoError(t, err)
+ if len(tt.patterns) == 0 {
+ assert.Empty(t, result)
+ }
+ }
+ })
+ }
+}
+
+func TestGlobIntegrationWithTestData(t *testing.T) {
+ // Test that our glob functionality works with the actual testdata files
+ // This ensures the feature works end-to-end in a realistic scenario
+
+ // Expand glob pattern for the example mapper files
+ expanded, err := expandGlobs([]string{"../../testdata/example-mapper*.yaml"})
+ require.NoError(t, err)
+
+ // Should match exactly the two mapper files
+ sort.Strings(expanded)
+ assert.Len(t, expanded, 2)
+ assert.Contains(t, expanded[0], "example-mapper1.yaml")
+ assert.Contains(t, expanded[1], "example-mapper2.yaml")
+
+ // Load configuration using the expanded files
+ config, err := tmconfig.LoadFromSources("", expanded)
+ require.NoError(t, err)
+
+ // Verify that both mappers are loaded correctly
+ require.Len(t, config.Lists, 2)
+
+ // Get the IDs to verify they match the expected ones
+ actualIDs := make([]string, len(config.Lists))
+ for i, list := range config.Lists {
+ actualIDs[i] = list.ID
+ }
+ sort.Strings(actualIDs)
+
+ expectedIDs := []string{"example-mapper-1", "example-mapper-2"}
+ assert.Equal(t, expectedIDs, actualIDs)
+
+ // Create mapper to ensure everything works
+ m, err := mapper.NewMapper(config.Lists)
+ require.NoError(t, err)
+ require.NotNil(t, m)
+
+ // Test that the mapper actually works with a real transformation
+ app := fiber.New()
+ setupRoutes(app, m, config)
+
+ // Test a transformation from example-mapper-1
+ testInput := `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/example-mapper-1/query?dir=atob", bytes.NewBufferString(testInput))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err = json.NewDecoder(resp.Body).Decode(&result)
+ require.NoError(t, err)
+
+ // Verify the transformation was applied
+ wrap := result["wrap"].(map[string]interface{})
+ assert.Equal(t, "koral:termGroup", wrap["@type"])
+ operands := wrap["operands"].([]interface{})
+ require.Greater(t, len(operands), 0)
+ firstOperand := operands[0].(map[string]interface{})
+ assert.Equal(t, "DET", firstOperand["key"])
+}
+
+func TestConfigurableServiceURL(t *testing.T) {
+ // Create test mapping list
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{
+ "[A] <> [B]",
+ },
+ }
+
+ tests := []struct {
+ name string
+ customServiceURL string
+ expectedServiceURL string
+ }{
+ {
+ name: "Custom service URL",
+ customServiceURL: "https://custom.example.com/plugin/koralmapper",
+ expectedServiceURL: "https://custom.example.com/plugin/koralmapper",
+ },
+ {
+ name: "Default service URL when not specified",
+ customServiceURL: "", // Will use default
+ expectedServiceURL: "https://korap.ids-mannheim.de/plugin/koralmapper",
+ },
+ {
+ name: "Custom service URL with different path",
+ customServiceURL: "https://my-server.org/api/v1/koralmapper",
+ expectedServiceURL: "https://my-server.org/api/v1/koralmapper",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create mapper
+ m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ require.NoError(t, err)
+
+ // Create mock config with custom service URL
+ mockConfig := &tmconfig.MappingConfig{
+ ServiceURL: tt.customServiceURL,
+ Lists: []tmconfig.MappingList{mappingList},
+ }
+
+ // Apply defaults to simulate the real loading process
+ tmconfig.ApplyDefaults(mockConfig)
+
+ // Create fiber app
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ // Test Kalamar plugin endpoint with a specific mapID
+ req := httptest.NewRequest(http.MethodGet, "/test-mapper", 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)
+
+ htmlContent := string(body)
+
+ // Check that the HTML contains the expected service URL in the JavaScript
+ expectedJSURL := tt.expectedServiceURL + "/test-mapper/query"
+ assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
+
+ // Ensure it's still a valid HTML page
+ assert.Contains(t, htmlContent, "Koral-Mapper")
+ assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+ })
+ }
+}
+
+func TestServiceURLConfigFileLoading(t *testing.T) {
+ // Create a temporary config file with custom service URL
+ configContent := `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+serviceURL: "https://custom.example.com/api/koralmapper"
+lists:
+- id: config-mapper
+ mappings:
+ - "[X] <> [Y]"
+`
+ configFile, err := os.CreateTemp("", "service-url-config-*.yaml")
+ require.NoError(t, err)
+ defer os.Remove(configFile.Name())
+
+ _, err = configFile.WriteString(configContent)
+ require.NoError(t, err)
+ err = configFile.Close()
+ require.NoError(t, err)
+
+ // Load configuration from file
+ config, err := tmconfig.LoadFromSources(configFile.Name(), nil)
+ require.NoError(t, err)
+
+ // Verify that the service URL was loaded correctly
+ assert.Equal(t, "https://custom.example.com/api/koralmapper", config.ServiceURL)
+
+ // Verify other fields are also preserved
+ assert.Equal(t, "https://custom.example.com/sdk.js", config.SDK)
+ assert.Equal(t, "https://custom.example.com/", config.Server)
+
+ // Create mapper and test the service URL is used in the HTML
+ m, err := mapper.NewMapper(config.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, config)
+
+ req := httptest.NewRequest(http.MethodGet, "/config-mapper", 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)
+
+ htmlContent := string(body)
+ expectedJSURL := "https://custom.example.com/api/koralmapper/config-mapper/query"
+ assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
+}
+
+func TestServiceURLDefaults(t *testing.T) {
+ // Test that defaults are applied correctly when creating a config
+ config := &tmconfig.MappingConfig{
+ Lists: []tmconfig.MappingList{
+ {
+ ID: "test",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ },
+ },
+ }
+
+ // Apply defaults (simulating what happens during loading)
+ tmconfig.ApplyDefaults(config)
+
+ // Check that the default service URL was applied
+ assert.Equal(t, "https://korap.ids-mannheim.de/plugin/koralmapper", config.ServiceURL)
+
+ // Check that other defaults were also applied
+ assert.Equal(t, "https://korap.ids-mannheim.de/", config.Server)
+ assert.Equal(t, "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", config.SDK)
+ assert.Equal(t, 5725, config.Port)
+ assert.Equal(t, "warn", config.LogLevel)
+}
+
+func TestServiceURLWithExampleConfig(t *testing.T) {
+ // Test that the actual example config file works with the new serviceURL functionality
+ // and that defaults are properly applied when serviceURL is not specified
+
+ config, err := tmconfig.LoadFromSources("../../testdata/example-config.yaml", nil)
+ require.NoError(t, err)
+
+ // Verify that the default service URL was applied since it's not in the example config
+ assert.Equal(t, "https://korap.ids-mannheim.de/plugin/koralmapper", config.ServiceURL)
+
+ // Verify other values from the example config are preserved
+ assert.Equal(t, "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", config.SDK)
+ assert.Equal(t, "https://korap.ids-mannheim.de/", config.Server)
+
+ // Verify the mapper was loaded correctly
+ require.Len(t, config.Lists, 1)
+ assert.Equal(t, "main-config-mapper", config.Lists[0].ID)
+
+ // Create mapper and test that the service URL is used correctly in the HTML
+ m, err := mapper.NewMapper(config.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, config)
+
+ req := httptest.NewRequest(http.MethodGet, "/main-config-mapper", 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)
+
+ htmlContent := string(body)
+ expectedJSURL := "https://korap.ids-mannheim.de/plugin/koralmapper/main-config-mapper/query"
+ assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
+}
+
+func TestGenerateKalamarPluginHTMLWithURLJoining(t *testing.T) {
+ tests := []struct {
+ name string
+ serviceURL string
+ mapID string
+ expected string
+ }{
+ {
+ name: "Service URL without trailing slash",
+ serviceURL: "https://example.com/plugin/koralmapper",
+ mapID: "test-mapper",
+ expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ },
+ {
+ name: "Service URL with trailing slash",
+ serviceURL: "https://example.com/plugin/koralmapper/",
+ mapID: "test-mapper",
+ expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ },
+ {
+ name: "Map ID with leading slash",
+ serviceURL: "https://example.com/plugin/koralmapper",
+ mapID: "/test-mapper",
+ expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ },
+ {
+ name: "Both with slashes",
+ serviceURL: "https://example.com/plugin/koralmapper/",
+ mapID: "/test-mapper",
+ expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ },
+ {
+ name: "Complex map ID",
+ serviceURL: "https://example.com/api/v1/",
+ mapID: "complex-mapper-name_123",
+ expected: "'service' : 'https://example.com/api/v1/complex-mapper-name_123/query",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ data := TemplateData{
+ Title: "Test Mapper",
+ Version: "1.0.0",
+ Hash: "abcd1234",
+ Date: "2024-01-01",
+ Description: "Test description",
+ Server: "https://example.com/",
+ SDK: "https://example.com/js/sdk.js",
+ ServiceURL: tt.serviceURL,
+ MapID: tt.mapID,
+ Mappings: []TemplateMapping{},
+ }
+
+ // Use default query parameters for this test
+ queryParams := QueryParams{
+ Dir: "atob",
+ FoundryA: "",
+ FoundryB: "",
+ LayerA: "",
+ LayerB: "",
+ }
+
+ html := generateKalamarPluginHTML(data, queryParams)
+ assert.Contains(t, html, tt.expected)
+ })
+ }
+}
+
+func TestKalamarPluginWithQueryParameters(t *testing.T) {
+ // Create test mapping list
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{
+ "[A] <> [B]",
+ },
+ }
+
+ // Create mapper
+ m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ require.NoError(t, err)
+
+ // Create mock config
+ mockConfig := &tmconfig.MappingConfig{
+ ServiceURL: "https://example.com/plugin/koralmapper",
+ Lists: []tmconfig.MappingList{mappingList},
+ }
+
+ // Apply defaults
+ tmconfig.ApplyDefaults(mockConfig)
+
+ // Create fiber app
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ tests := []struct {
+ name string
+ url string
+ expectedQueryURL string
+ expectedRespURL string
+ expectedStatus int
+ expectedError string
+ }{
+ {
+ name: "Default parameters (no query params)",
+ url: "/test-mapper",
+ expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
+ expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "Explicit dir=atob",
+ url: "/test-mapper?dir=atob",
+ expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
+ expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "Explicit dir=btoa",
+ url: "/test-mapper?dir=btoa",
+ expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=btoa",
+ expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=atob",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "With foundry parameters",
+ url: "/test-mapper?dir=atob&foundryA=opennlp&foundryB=upos",
+ expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob&foundryA=opennlp&foundryB=upos",
+ expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa&foundryA=opennlp&foundryB=upos",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "With layer parameters",
+ url: "/test-mapper?dir=btoa&layerA=pos&layerB=upos",
+ expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=btoa&layerA=pos&layerB=upos",
+ expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=atob&layerA=pos&layerB=upos",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "All parameters",
+ url: "/test-mapper?dir=atob&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
+ expectedQueryURL: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
+ expectedRespURL: "https://example.com/plugin/koralmapper/test-mapper/response?dir=btoa&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
+ expectedStatus: http.StatusOK,
+ },
+ {
+ name: "Invalid direction",
+ url: "/test-mapper?dir=invalid",
+ expectedStatus: http.StatusBadRequest,
+ expectedError: "invalid direction, must be 'atob' or 'btoa'",
+ },
+ {
+ name: "Parameter too long",
+ url: "/test-mapper?foundryA=" + strings.Repeat("a", 1025),
+ expectedStatus: http.StatusBadRequest,
+ expectedError: "foundryA too long (max 1024 bytes)",
+ },
+ {
+ name: "Invalid characters in parameter",
+ url: "/test-mapper?foundryA=invalid<>chars",
+ expectedStatus: http.StatusBadRequest,
+ expectedError: "foundryA contains invalid characters",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, tt.url, nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, tt.expectedStatus, resp.StatusCode)
+
+ 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 {
+ htmlContent := string(body)
+
+ // Check that both query and response URLs are present with correct parameters
+ assert.Contains(t, htmlContent, "'service' : '"+tt.expectedQueryURL+"'")
+ assert.Contains(t, htmlContent, "'service' : '"+tt.expectedRespURL+"'")
+
+ // Ensure it's still a valid HTML page
+ assert.Contains(t, htmlContent, "Koral-Mapper")
+ assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+ }
+ })
+ }
+}
+
+func TestBuildQueryParams(t *testing.T) {
+ tests := []struct {
+ name string
+ dir string
+ foundryA string
+ foundryB string
+ layerA string
+ layerB string
+ expected string
+ }{
+ {
+ name: "Only direction parameter",
+ dir: "atob",
+ expected: "dir=atob",
+ },
+ {
+ name: "All parameters",
+ dir: "btoa",
+ foundryA: "opennlp",
+ foundryB: "upos",
+ layerA: "pos",
+ layerB: "upos",
+ expected: "dir=btoa&foundryA=opennlp&foundryB=upos&layerA=pos&layerB=upos",
+ },
+ {
+ name: "Some parameters empty",
+ dir: "atob",
+ foundryA: "opennlp",
+ foundryB: "",
+ layerA: "pos",
+ layerB: "",
+ expected: "dir=atob&foundryA=opennlp&layerA=pos",
+ },
+ {
+ name: "All parameters empty",
+ dir: "",
+ foundryA: "",
+ foundryB: "",
+ layerA: "",
+ layerB: "",
+ expected: "",
+ },
+ {
+ name: "URL encoding needed",
+ dir: "atob",
+ foundryA: "test space",
+ foundryB: "test&special",
+ expected: "dir=atob&foundryA=test+space&foundryB=test%26special",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := buildQueryParams(tt.dir, tt.foundryA, tt.foundryB, tt.layerA, tt.layerB)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}