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)
+		})
+	}
+}