blob: c1fbabe008c0e1cc8d8fd6578d80fefd6670e04d [file] [log] [blame]
Akron49ceeb42025-05-23 17:46:01 +02001package main
2
3import (
Akrond8a76b32026-02-20 09:31:56 +01004 "bytes"
5 "embed"
Akron49ceeb42025-05-23 17:46:01 +02006 "fmt"
Akrond8a76b32026-02-20 09:31:56 +01007 "html/template"
8 "io/fs"
Akron80067202025-06-06 14:16:25 +02009 "net/url"
Akron49ceeb42025-05-23 17:46:01 +020010 "os"
11 "os/signal"
Akron80067202025-06-06 14:16:25 +020012 "path"
Akron14678dc2025-06-05 13:01:38 +020013 "path/filepath"
Akrond8a76b32026-02-20 09:31:56 +010014 "strconv"
Akron49ceeb42025-05-23 17:46:01 +020015 "strings"
16 "syscall"
Akron3caee162025-07-01 17:44:58 +020017 "time"
Akron49ceeb42025-05-23 17:46:01 +020018
Akron2ef703c2025-07-03 15:57:42 +020019 "github.com/KorAP/Koral-Mapper/config"
20 "github.com/KorAP/Koral-Mapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020021 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020022 "github.com/gofiber/fiber/v2"
23 "github.com/rs/zerolog"
24 "github.com/rs/zerolog/log"
25)
26
Akrond8a76b32026-02-20 09:31:56 +010027//go:embed static/*
28var staticFS embed.FS
29
Akron74e1c072025-05-26 14:38:25 +020030const (
31 maxInputLength = 1024 * 1024 // 1MB
32 maxParamLength = 1024 // 1KB
33)
34
Akrona00d4752025-05-26 17:34:36 +020035type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020036 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020037 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020038 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020039 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020040}
41
Akrond8a76b32026-02-20 09:31:56 +010042type BasePageData struct {
Akron40aaa632025-06-03 17:57:52 +020043 Title string
44 Version string
Akronfc77b5e2025-06-04 11:44:43 +020045 Hash string
46 Date string
Akron40aaa632025-06-03 17:57:52 +020047 Description string
Akron06d21f02025-06-04 14:36:07 +020048 Server string
49 SDK string
Akron43fb1022026-02-20 11:38:49 +010050 Stylesheet string
Akron2ac2ec02025-06-05 15:26:42 +020051 ServiceURL string
Akron43fb1022026-02-20 11:38:49 +010052 CookieName string
Akrond8a76b32026-02-20 09:31:56 +010053}
54
55type SingleMappingPageData struct {
56 BasePageData
Akronc376dcc2025-06-04 17:00:18 +020057 MapID string
Akrond8a76b32026-02-20 09:31:56 +010058 Mappings []config.MappingList
59 QueryURL string
60 ResponseURL string
Akron40aaa632025-06-03 17:57:52 +020061}
62
Akroncb51f812025-06-30 15:24:20 +020063type QueryParams struct {
64 Dir string
65 FoundryA string
66 FoundryB string
67 LayerA string
68 LayerB string
69}
70
Akron49b525c2025-07-03 15:17:06 +020071// requestParams holds common request parameters
72type requestParams struct {
73 MapID string
74 Dir string
75 FoundryA string
76 FoundryB string
77 LayerA string
78 LayerB string
Akron8414ae52026-05-19 13:31:14 +020079 Rewrites *bool // nil = use mapping list default; non-nil = override
Akron49b525c2025-07-03 15:17:06 +020080}
81
Akron247a93a2026-02-20 16:28:40 +010082// MappingSectionData contains per-section UI metadata so request and response
83// rows can be rendered from one shared template block.
84type MappingSectionData struct {
85 Title string
86 Mode string
87 CheckboxClass string
88 CheckboxName string
89 FieldsClass string
90 ArrowClass string
91 ArrowDirection string
92 ArrowLabel string
93 AnnotationLabel string
94}
95
Akrond8a76b32026-02-20 09:31:56 +010096// ConfigPageData holds all data passed to the configuration page template.
97type ConfigPageData struct {
98 BasePageData
99 AnnotationMappings []config.MappingList
100 CorpusMappings []config.MappingList
Akron247a93a2026-02-20 16:28:40 +0100101 MappingSections []MappingSectionData
Akrond8a76b32026-02-20 09:31:56 +0100102}
103
Akrona00d4752025-05-26 17:34:36 +0200104func parseConfig() *appConfig {
105 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +0200106
107 desc := config.Description
108 desc += " [" + config.Version + "]"
109
Akron1fc750e2025-05-26 16:54:18 +0200110 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +0200111 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +0200112 kong.UsageOnError(),
113 )
114 if ctx.Error != nil {
115 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +0200116 os.Exit(1)
117 }
Akron49ceeb42025-05-23 17:46:01 +0200118 return cfg
119}
120
121func setupLogger(level string) {
122 // Parse log level
123 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
124 if err != nil {
125 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
126 lvl = zerolog.InfoLevel
127 }
128
129 // Configure zerolog
130 zerolog.SetGlobalLevel(lvl)
131 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
132}
133
Akron3caee162025-07-01 17:44:58 +0200134// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
135func setupFiberLogger() fiber.Handler {
136 // Check if HTTP request logging should be enabled based on current log level
137 currentLevel := zerolog.GlobalLevel()
138
139 // Only enable HTTP request logging if log level is debug or info
140 if currentLevel > zerolog.InfoLevel {
141 return func(c *fiber.Ctx) error {
142 return c.Next()
143 }
144 }
145
146 return func(c *fiber.Ctx) error {
147 // Record start time
148 start := time.Now()
149
150 // Process request
151 err := c.Next()
152
153 // Calculate latency
154 latency := time.Since(start)
155 status := c.Response().StatusCode()
156
157 // Determine log level based on status code
158 logEvent := log.Info()
159 if status >= 400 && status < 500 {
160 logEvent = log.Warn()
161 } else if status >= 500 {
162 logEvent = log.Error()
163 }
164
165 // Log the request
166 logEvent.
167 Int("status", status).
168 Dur("latency", latency).
169 Str("method", c.Method()).
170 Str("path", c.Path()).
171 Str("ip", c.IP()).
172 Str("user_agent", c.Get("User-Agent")).
173 Msg("HTTP request")
174
175 return err
176 }
177}
178
Akron49b525c2025-07-03 15:17:06 +0200179// extractRequestParams extracts and validates common request parameters
180func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
181 params := &requestParams{
182 MapID: c.Params("map"),
183 Dir: c.Query("dir", "atob"),
184 FoundryA: c.Query("foundryA", ""),
185 FoundryB: c.Query("foundryB", ""),
186 LayerA: c.Query("layerA", ""),
187 LayerB: c.Query("layerB", ""),
188 }
189
Akron8414ae52026-05-19 13:31:14 +0200190 if rewrites := c.Query("rewrites", ""); rewrites != "" {
191 v := rewrites == "true"
192 params.Rewrites = &v
193 }
194
Akron49b525c2025-07-03 15:17:06 +0200195 // Validate input parameters
196 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
197 return nil, err
198 }
199
200 // Validate direction
201 if params.Dir != "atob" && params.Dir != "btoa" {
202 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
203 }
204
205 return params, nil
206}
207
208// parseRequestBody parses JSON request body and direction
209func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
210 var jsonData any
211 if err := c.BodyParser(&jsonData); err != nil {
212 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
213 }
214
215 direction, err := mapper.ParseDirection(dir)
216 if err != nil {
217 return nil, mapper.BtoA, err
218 }
219
220 return jsonData, direction, nil
221}
222
Akron49ceeb42025-05-23 17:46:01 +0200223func main() {
224 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200225 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200226
Akrone1cff7c2025-06-04 18:43:32 +0200227 // Validate command line arguments
228 if cfg.Config == "" && len(cfg.Mappings) == 0 {
229 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
230 }
231
Akron14678dc2025-06-05 13:01:38 +0200232 // Expand glob patterns in mapping files
233 expandedMappings, err := expandGlobs(cfg.Mappings)
234 if err != nil {
235 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
236 }
237
Akrone1cff7c2025-06-04 18:43:32 +0200238 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200239 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200240 if err != nil {
241 log.Fatal().Err(err).Msg("Failed to load configuration")
242 }
243
Akrona8a66ce2025-06-05 10:50:17 +0200244 finalPort := yamlConfig.Port
245 finalLogLevel := yamlConfig.LogLevel
246
247 // Use command line values if provided (they override config file)
248 if cfg.Port != nil {
249 finalPort = *cfg.Port
250 }
251 if cfg.LogLevel != nil {
252 finalLogLevel = *cfg.LogLevel
253 }
254
255 // Set up logging with the final log level
256 setupLogger(finalLogLevel)
257
Akron49ceeb42025-05-23 17:46:01 +0200258 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200259 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200260 if err != nil {
261 log.Fatal().Err(err).Msg("Failed to create mapper")
262 }
263
264 // Create fiber app
265 app := fiber.New(fiber.Config{
266 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200267 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200268 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
269 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200270 })
271
Akron3caee162025-07-01 17:44:58 +0200272 // Add zerolog-integrated logger middleware
273 app.Use(setupFiberLogger())
274
Akron49ceeb42025-05-23 17:46:01 +0200275 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200276 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200277
278 // Start server
279 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200280 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100281 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200282
283 for _, list := range yamlConfig.Lists {
284 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100285 fmt.Printf("Loaded mapping desc=%s id=%s\n",
286 formatConsoleField(list.Description),
287 list.ID,
288 )
Akronae3ffde2025-06-05 14:04:06 +0200289 }
290
Akrona8a66ce2025-06-05 10:50:17 +0200291 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200292 log.Fatal().Err(err).Msg("Server error")
293 }
294 }()
295
296 // Wait for interrupt signal
297 sigChan := make(chan os.Signal, 1)
298 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
299 <-sigChan
300
301 // Graceful shutdown
302 log.Info().Msg("Shutting down server")
303 if err := app.Shutdown(); err != nil {
304 log.Error().Err(err).Msg("Error during shutdown")
305 }
306}
307
Akron06d21f02025-06-04 14:36:07 +0200308func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100309 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
Akronbeee5052026-05-20 09:39:45 +0200310 pluginTmpl := template.Must(template.ParseFS(staticFS, "static/plugin.html"))
Akrond8a76b32026-02-20 09:31:56 +0100311
Akron49ceeb42025-05-23 17:46:01 +0200312 // Health check endpoint
313 app.Get("/health", func(c *fiber.Ctx) error {
314 return c.SendString("OK")
315 })
316
Akrond8a76b32026-02-20 09:31:56 +0100317 // Static file serving from embedded FS
318 app.Get("/static/*", handleStaticFile())
319
Akronbf73a122026-02-27 15:02:16 +0100320 // Composite cascade transformation endpoints (cfg in path)
321 app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig.Lists))
322 app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig.Lists))
Akron512aab62026-02-20 08:36:12 +0100323
Akron49ceeb42025-05-23 17:46:01 +0200324 // Transformation endpoint
Akron8414ae52026-05-19 13:31:14 +0200325 app.Post("/:map/query", handleTransform(m, yamlConfig.Lists))
Akron40aaa632025-06-03 17:57:52 +0200326
Akron4de47a92025-06-27 11:58:11 +0200327 // Response transformation endpoint
Akron8414ae52026-05-19 13:31:14 +0200328 app.Post("/:map/response", handleResponseTransform(m, yamlConfig.Lists))
Akron4de47a92025-06-27 11:58:11 +0200329
Akron40aaa632025-06-03 17:57:52 +0200330 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100331 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
332 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
333}
334
335func handleStaticFile() fiber.Handler {
336 return func(c *fiber.Ctx) error {
337 name := c.Params("*")
338 data, err := fs.ReadFile(staticFS, "static/"+name)
339 if err != nil {
340 return c.Status(fiber.StatusNotFound).SendString("not found")
341 }
342 switch {
343 case strings.HasSuffix(name, ".js"):
344 c.Set("Content-Type", "text/javascript; charset=utf-8")
345 case strings.HasSuffix(name, ".css"):
346 c.Set("Content-Type", "text/css; charset=utf-8")
347 case strings.HasSuffix(name, ".html"):
348 c.Set("Content-Type", "text/html; charset=utf-8")
349 }
350 return c.Send(data)
351 }
352}
353
354func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
355 return BasePageData{
356 Title: config.Title,
357 Version: config.Version,
358 Hash: config.Buildhash,
359 Date: config.Buildtime,
360 Description: config.Description,
361 Server: yamlConfig.Server,
362 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100363 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100364 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100365 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100366 }
367}
368
369func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
370 data := ConfigPageData{
371 BasePageData: buildBasePageData(yamlConfig),
372 }
373
374 for _, list := range yamlConfig.Lists {
375 normalized := list
376 if normalized.Type == "" {
377 normalized.Type = "annotation"
378 }
379 if list.IsCorpus() {
380 data.CorpusMappings = append(data.CorpusMappings, normalized)
381 } else {
382 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
383 }
384 }
Akron247a93a2026-02-20 16:28:40 +0100385
386 data.MappingSections = []MappingSectionData{
387 {
Akron8bdf5202026-02-24 10:01:15 +0100388 Title: "Request",
389 Mode: "request",
390 CheckboxClass: "request-cb",
391 CheckboxName: "request",
392 FieldsClass: "request-fields",
393 ArrowClass: "request-dir-arrow",
394 ArrowDirection: "atob",
395 ArrowLabel: "\u2192",
Akron247a93a2026-02-20 16:28:40 +0100396 },
397 {
Akron8bdf5202026-02-24 10:01:15 +0100398 Title: "Response",
399 Mode: "response",
400 CheckboxClass: "response-cb",
401 CheckboxName: "response",
402 FieldsClass: "response-fields",
403 ArrowClass: "response-dir-arrow",
404 ArrowDirection: "btoa",
405 ArrowLabel: "\u2190",
Akron247a93a2026-02-20 16:28:40 +0100406 },
407 }
408
Akrond8a76b32026-02-20 09:31:56 +0100409 return data
Akron49ceeb42025-05-23 17:46:01 +0200410}
411
Akron512aab62026-02-20 08:36:12 +0100412func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
Akron8414ae52026-05-19 13:31:14 +0200413 listsByID := make(map[string]*config.MappingList, len(lists))
414 for i := range lists {
415 listsByID[lists[i].ID] = &lists[i]
416 }
417
Akron512aab62026-02-20 08:36:12 +0100418 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100419 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100420 if len(cfgRaw) > maxParamLength {
421 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
422 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
423 })
424 }
425
426 var jsonData any
427 if err := c.BodyParser(&jsonData); err != nil {
428 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
429 "error": "invalid JSON in request body",
430 })
431 }
432
433 entries, err := ParseCfgParam(cfgRaw, lists)
434 if err != nil {
435 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
436 "error": err.Error(),
437 })
438 }
439
440 if len(entries) == 0 {
441 return c.JSON(jsonData)
442 }
443
Akron8414ae52026-05-19 13:31:14 +0200444 rewrites := c.Query("rewrites", "")
445 var rewritesOverride *bool
446 if rewrites != "" {
447 v := rewrites == "true"
448 rewritesOverride = &v
449 }
450
Akron512aab62026-02-20 08:36:12 +0100451 orderedIDs := make([]string, 0, len(entries))
452 opts := make([]mapper.MappingOptions, 0, len(entries))
453 for _, entry := range entries {
454 dir := mapper.AtoB
455 if entry.Direction == "btoa" {
456 dir = mapper.BtoA
457 }
458
Akron8414ae52026-05-19 13:31:14 +0200459 addRewrites := false
460 if list, ok := listsByID[entry.ID]; ok {
461 addRewrites = list.Rewrites
462 }
463 if rewritesOverride != nil {
464 addRewrites = *rewritesOverride
465 }
466
Akron512aab62026-02-20 08:36:12 +0100467 orderedIDs = append(orderedIDs, entry.ID)
468 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200469 Direction: dir,
470 FoundryA: entry.FoundryA,
471 LayerA: entry.LayerA,
472 FoundryB: entry.FoundryB,
473 LayerB: entry.LayerB,
474 FieldA: entry.FieldA,
475 FieldB: entry.FieldB,
476 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100477 })
478 }
479
480 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
481 if err != nil {
482 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
483 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
484 "error": err.Error(),
485 })
486 }
487
488 return c.JSON(result)
489 }
490}
491
492func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
Akron8414ae52026-05-19 13:31:14 +0200493 listsByID := make(map[string]*config.MappingList, len(lists))
494 for i := range lists {
495 listsByID[lists[i].ID] = &lists[i]
496 }
497
Akron512aab62026-02-20 08:36:12 +0100498 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100499 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100500 if len(cfgRaw) > maxParamLength {
501 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
502 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
503 })
504 }
505
506 var jsonData any
507 if err := c.BodyParser(&jsonData); err != nil {
508 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
509 "error": "invalid JSON in request body",
510 })
511 }
512
513 entries, err := ParseCfgParam(cfgRaw, lists)
514 if err != nil {
515 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
516 "error": err.Error(),
517 })
518 }
519
520 if len(entries) == 0 {
521 return c.JSON(jsonData)
522 }
523
Akron8414ae52026-05-19 13:31:14 +0200524 rewrites := c.Query("rewrites", "")
525 var rewritesOverride *bool
526 if rewrites != "" {
527 v := rewrites == "true"
528 rewritesOverride = &v
529 }
530
Akron512aab62026-02-20 08:36:12 +0100531 orderedIDs := make([]string, 0, len(entries))
532 opts := make([]mapper.MappingOptions, 0, len(entries))
533 for _, entry := range entries {
534 dir := mapper.AtoB
535 if entry.Direction == "btoa" {
536 dir = mapper.BtoA
537 }
538
Akron8414ae52026-05-19 13:31:14 +0200539 addRewrites := false
540 if list, ok := listsByID[entry.ID]; ok {
541 addRewrites = list.Rewrites
542 }
543 if rewritesOverride != nil {
544 addRewrites = *rewritesOverride
545 }
546
Akron512aab62026-02-20 08:36:12 +0100547 orderedIDs = append(orderedIDs, entry.ID)
548 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200549 Direction: dir,
550 FoundryA: entry.FoundryA,
551 LayerA: entry.LayerA,
552 FoundryB: entry.FoundryB,
553 LayerB: entry.LayerB,
554 FieldA: entry.FieldA,
555 FieldB: entry.FieldB,
556 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100557 })
558 }
559
560 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
561 if err != nil {
562 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
563 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
564 "error": err.Error(),
565 })
566 }
567
568 return c.JSON(result)
569 }
570}
571
Akron8414ae52026-05-19 13:31:14 +0200572func handleTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
573 listsByID := make(map[string]*config.MappingList, len(lists))
574 for i := range lists {
575 listsByID[lists[i].ID] = &lists[i]
576 }
577
Akron49ceeb42025-05-23 17:46:01 +0200578 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200579 // Extract and validate parameters
580 params, err := extractRequestParams(c)
581 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200582 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
583 "error": err.Error(),
584 })
585 }
586
Akron49ceeb42025-05-23 17:46:01 +0200587 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200588 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200589 if err != nil {
590 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
591 "error": err.Error(),
592 })
593 }
594
Akron8414ae52026-05-19 13:31:14 +0200595 // Determine rewrites: query param overrides YAML default
596 addRewrites := false
597 if list, ok := listsByID[params.MapID]; ok {
598 addRewrites = list.Rewrites
599 }
600 if params.Rewrites != nil {
601 addRewrites = *params.Rewrites
602 }
603
Akron49ceeb42025-05-23 17:46:01 +0200604 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200605 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200606 Direction: direction,
607 FoundryA: params.FoundryA,
608 FoundryB: params.FoundryB,
609 LayerA: params.LayerA,
610 LayerB: params.LayerB,
611 AddRewrites: addRewrites,
Akron49ceeb42025-05-23 17:46:01 +0200612 }, jsonData)
613
614 if err != nil {
615 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200616 Str("mapID", params.MapID).
617 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200618 Msg("Failed to apply mappings")
619
620 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
621 "error": err.Error(),
622 })
623 }
624
625 return c.JSON(result)
626 }
627}
Akron74e1c072025-05-26 14:38:25 +0200628
Akron8414ae52026-05-19 13:31:14 +0200629func handleResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
630 listsByID := make(map[string]*config.MappingList, len(lists))
631 for i := range lists {
632 listsByID[lists[i].ID] = &lists[i]
633 }
634
Akron4de47a92025-06-27 11:58:11 +0200635 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200636 // Extract and validate parameters
637 params, err := extractRequestParams(c)
638 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200639 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
640 "error": err.Error(),
641 })
642 }
643
Akron4de47a92025-06-27 11:58:11 +0200644 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200645 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200646 if err != nil {
647 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
648 "error": err.Error(),
649 })
650 }
651
Akron8414ae52026-05-19 13:31:14 +0200652 // Determine rewrites: query param overrides YAML default
653 addRewrites := false
654 if list, ok := listsByID[params.MapID]; ok {
655 addRewrites = list.Rewrites
656 }
657 if params.Rewrites != nil {
658 addRewrites = *params.Rewrites
659 }
660
Akron4de47a92025-06-27 11:58:11 +0200661 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200662 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200663 Direction: direction,
664 FoundryA: params.FoundryA,
665 FoundryB: params.FoundryB,
666 LayerA: params.LayerA,
667 LayerB: params.LayerB,
668 AddRewrites: addRewrites,
Akron4de47a92025-06-27 11:58:11 +0200669 }, jsonData)
670
671 if err != nil {
672 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200673 Str("mapID", params.MapID).
674 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200675 Msg("Failed to apply response mappings")
676
677 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
678 "error": err.Error(),
679 })
680 }
681
682 return c.JSON(result)
683 }
684}
685
Akron74e1c072025-05-26 14:38:25 +0200686// validateInput checks if the input parameters are valid
687func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200688 // Define parameter checks
689 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200690 name string
691 value string
692 }{
693 {"mapID", mapID},
694 {"dir", dir},
695 {"foundryA", foundryA},
696 {"foundryB", foundryB},
697 {"layerA", layerA},
698 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200699 }
700
701 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200702 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200703 if len(param.value) > maxParamLength {
704 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
705 }
Akron74e1c072025-05-26 14:38:25 +0200706 if strings.ContainsAny(param.value, "<>{}[]\\") {
707 return fmt.Errorf("%s contains invalid characters", param.name)
708 }
709 }
710
Akron69d43bf2025-05-26 17:09:00 +0200711 if len(body) > maxInputLength {
712 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
713 }
714
Akron74e1c072025-05-26 14:38:25 +0200715 return nil
716}
Akron40aaa632025-06-03 17:57:52 +0200717
Akronbeee5052026-05-20 09:39:45 +0200718func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *template.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200719 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200720 mapID := c.Params("map")
721
Akrond8a76b32026-02-20 09:31:56 +0100722 // Config page (GET /)
723 if mapID == "" {
724 data := buildConfigPageData(yamlConfig)
725 var buf bytes.Buffer
726 if err := configTmpl.Execute(&buf, data); err != nil {
727 log.Error().Err(err).Msg("Failed to execute config template")
728 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
729 }
730 c.Set("Content-Type", "text/html")
731 return c.Send(buf.Bytes())
732 }
733
734 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200735 // Get query parameters
736 dir := c.Query("dir", "atob")
737 foundryA := c.Query("foundryA", "")
738 foundryB := c.Query("foundryB", "")
739 layerA := c.Query("layerA", "")
740 layerB := c.Query("layerB", "")
741
Akron49b525c2025-07-03 15:17:06 +0200742 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200743 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
744 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
745 "error": err.Error(),
746 })
747 }
748
Akroncb51f812025-06-30 15:24:20 +0200749 if dir != "atob" && dir != "btoa" {
750 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
751 "error": "invalid direction, must be 'atob' or 'btoa'",
752 })
753 }
754
Akroncb51f812025-06-30 15:24:20 +0200755 queryParams := QueryParams{
756 Dir: dir,
757 FoundryA: foundryA,
758 FoundryB: foundryB,
759 LayerA: layerA,
760 LayerB: layerB,
761 }
762
Akrond8a76b32026-02-20 09:31:56 +0100763 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
764 if err != nil {
765 log.Warn().Err(err).Msg("Failed to build query service URL")
766 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
767 }
768 reversed := queryParams
769 if queryParams.Dir == "btoa" {
770 reversed.Dir = "atob"
771 } else {
772 reversed.Dir = "btoa"
773 }
774 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
775 if err != nil {
776 log.Warn().Err(err).Msg("Failed to build response service URL")
777 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
778 }
Akron40aaa632025-06-03 17:57:52 +0200779
Akrond8a76b32026-02-20 09:31:56 +0100780 data := SingleMappingPageData{
781 BasePageData: buildBasePageData(yamlConfig),
782 MapID: mapID,
783 Mappings: yamlConfig.Lists,
784 QueryURL: queryURL,
785 ResponseURL: responseURL,
786 }
787
788 var buf bytes.Buffer
789 if err := pluginTmpl.Execute(&buf, data); err != nil {
790 log.Error().Err(err).Msg("Failed to execute plugin template")
791 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
792 }
Akron40aaa632025-06-03 17:57:52 +0200793 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100794 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200795 }
796}
797
Akrond8a76b32026-02-20 09:31:56 +0100798func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
799 service, err := url.Parse(serviceURL)
800 if err != nil {
801 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200802 }
Akrond8a76b32026-02-20 09:31:56 +0100803 service.Path = path.Join(service.Path, mapID, endpoint)
804 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
805 return service.String(), nil
806}
Akronc376dcc2025-06-04 17:00:18 +0200807
Akrond8a76b32026-02-20 09:31:56 +0100808func formatConsoleField(value string) string {
809 if strings.ContainsAny(value, " \t") {
810 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200811 }
Akrond8a76b32026-02-20 09:31:56 +0100812 return value
Akron40aaa632025-06-03 17:57:52 +0200813}
Akron14678dc2025-06-05 13:01:38 +0200814
Akroncb51f812025-06-30 15:24:20 +0200815// buildQueryParams builds a query string from the provided parameters
816func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
817 params := url.Values{}
818 if dir != "" {
819 params.Add("dir", dir)
820 }
821 if foundryA != "" {
822 params.Add("foundryA", foundryA)
823 }
824 if foundryB != "" {
825 params.Add("foundryB", foundryB)
826 }
827 if layerA != "" {
828 params.Add("layerA", layerA)
829 }
830 if layerB != "" {
831 params.Add("layerB", layerB)
832 }
833 return params.Encode()
834}
835
Akron14678dc2025-06-05 13:01:38 +0200836// expandGlobs expands glob patterns in the slice of file paths
837// Returns the expanded list of files or an error if glob expansion fails
838func expandGlobs(patterns []string) ([]string, error) {
839 var expanded []string
840
841 for _, pattern := range patterns {
842 // Use filepath.Glob which works cross-platform
843 matches, err := filepath.Glob(pattern)
844 if err != nil {
845 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
846 }
847
848 // If no matches found, treat as literal filename (consistent with shell behavior)
849 if len(matches) == 0 {
850 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
851 expanded = append(expanded, pattern)
852 } else {
853 expanded = append(expanded, matches...)
854 }
855 }
856
857 return expanded, nil
858}