blob: 75a1d9c74123d69f5338a73a17a6b492aa61aaf7 [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"
Akrond8a76b32026-02-20 09:31:56 +010017 texttemplate "text/template"
Akron3caee162025-07-01 17:44:58 +020018 "time"
Akron49ceeb42025-05-23 17:46:01 +020019
Akron2ef703c2025-07-03 15:57:42 +020020 "github.com/KorAP/Koral-Mapper/config"
21 "github.com/KorAP/Koral-Mapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020022 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020023 "github.com/gofiber/fiber/v2"
24 "github.com/rs/zerolog"
25 "github.com/rs/zerolog/log"
26)
27
Akrond8a76b32026-02-20 09:31:56 +010028//go:embed static/*
29var staticFS embed.FS
30
Akron74e1c072025-05-26 14:38:25 +020031const (
32 maxInputLength = 1024 * 1024 // 1MB
33 maxParamLength = 1024 // 1KB
34)
35
Akrona00d4752025-05-26 17:34:36 +020036type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020037 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020038 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020039 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020040 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020041}
42
Akrond8a76b32026-02-20 09:31:56 +010043type BasePageData struct {
Akron40aaa632025-06-03 17:57:52 +020044 Title string
45 Version string
Akronfc77b5e2025-06-04 11:44:43 +020046 Hash string
47 Date string
Akron40aaa632025-06-03 17:57:52 +020048 Description string
Akron06d21f02025-06-04 14:36:07 +020049 Server string
50 SDK string
Akron43fb1022026-02-20 11:38:49 +010051 Stylesheet string
Akron2ac2ec02025-06-05 15:26:42 +020052 ServiceURL string
Akron43fb1022026-02-20 11:38:49 +010053 CookieName string
Akrond8a76b32026-02-20 09:31:56 +010054}
55
56type SingleMappingPageData struct {
57 BasePageData
Akronc376dcc2025-06-04 17:00:18 +020058 MapID string
Akrond8a76b32026-02-20 09:31:56 +010059 Mappings []config.MappingList
60 QueryURL string
61 ResponseURL string
Akron40aaa632025-06-03 17:57:52 +020062}
63
Akroncb51f812025-06-30 15:24:20 +020064type QueryParams struct {
65 Dir string
66 FoundryA string
67 FoundryB string
68 LayerA string
69 LayerB string
70}
71
Akron49b525c2025-07-03 15:17:06 +020072// requestParams holds common request parameters
73type requestParams struct {
74 MapID string
75 Dir string
76 FoundryA string
77 FoundryB string
78 LayerA string
79 LayerB string
Akron8414ae52026-05-19 13:31:14 +020080 Rewrites *bool // nil = use mapping list default; non-nil = override
Akron49b525c2025-07-03 15:17:06 +020081}
82
Akron247a93a2026-02-20 16:28:40 +010083// MappingSectionData contains per-section UI metadata so request and response
84// rows can be rendered from one shared template block.
85type MappingSectionData struct {
86 Title string
87 Mode string
88 CheckboxClass string
89 CheckboxName string
90 FieldsClass string
91 ArrowClass string
92 ArrowDirection string
93 ArrowLabel string
94 AnnotationLabel string
95}
96
Akrond8a76b32026-02-20 09:31:56 +010097// ConfigPageData holds all data passed to the configuration page template.
98type ConfigPageData struct {
99 BasePageData
100 AnnotationMappings []config.MappingList
101 CorpusMappings []config.MappingList
Akron247a93a2026-02-20 16:28:40 +0100102 MappingSections []MappingSectionData
Akrond8a76b32026-02-20 09:31:56 +0100103}
104
Akrona00d4752025-05-26 17:34:36 +0200105func parseConfig() *appConfig {
106 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +0200107
108 desc := config.Description
109 desc += " [" + config.Version + "]"
110
Akron1fc750e2025-05-26 16:54:18 +0200111 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +0200112 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +0200113 kong.UsageOnError(),
114 )
115 if ctx.Error != nil {
116 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +0200117 os.Exit(1)
118 }
Akron49ceeb42025-05-23 17:46:01 +0200119 return cfg
120}
121
122func setupLogger(level string) {
123 // Parse log level
124 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
125 if err != nil {
126 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
127 lvl = zerolog.InfoLevel
128 }
129
130 // Configure zerolog
131 zerolog.SetGlobalLevel(lvl)
132 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
133}
134
Akron3caee162025-07-01 17:44:58 +0200135// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
136func setupFiberLogger() fiber.Handler {
137 // Check if HTTP request logging should be enabled based on current log level
138 currentLevel := zerolog.GlobalLevel()
139
140 // Only enable HTTP request logging if log level is debug or info
141 if currentLevel > zerolog.InfoLevel {
142 return func(c *fiber.Ctx) error {
143 return c.Next()
144 }
145 }
146
147 return func(c *fiber.Ctx) error {
148 // Record start time
149 start := time.Now()
150
151 // Process request
152 err := c.Next()
153
154 // Calculate latency
155 latency := time.Since(start)
156 status := c.Response().StatusCode()
157
158 // Determine log level based on status code
159 logEvent := log.Info()
160 if status >= 400 && status < 500 {
161 logEvent = log.Warn()
162 } else if status >= 500 {
163 logEvent = log.Error()
164 }
165
166 // Log the request
167 logEvent.
168 Int("status", status).
169 Dur("latency", latency).
170 Str("method", c.Method()).
171 Str("path", c.Path()).
172 Str("ip", c.IP()).
173 Str("user_agent", c.Get("User-Agent")).
174 Msg("HTTP request")
175
176 return err
177 }
178}
179
Akron49b525c2025-07-03 15:17:06 +0200180// extractRequestParams extracts and validates common request parameters
181func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
182 params := &requestParams{
183 MapID: c.Params("map"),
184 Dir: c.Query("dir", "atob"),
185 FoundryA: c.Query("foundryA", ""),
186 FoundryB: c.Query("foundryB", ""),
187 LayerA: c.Query("layerA", ""),
188 LayerB: c.Query("layerB", ""),
189 }
190
Akron8414ae52026-05-19 13:31:14 +0200191 if rewrites := c.Query("rewrites", ""); rewrites != "" {
192 v := rewrites == "true"
193 params.Rewrites = &v
194 }
195
Akron49b525c2025-07-03 15:17:06 +0200196 // Validate input parameters
197 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
198 return nil, err
199 }
200
201 // Validate direction
202 if params.Dir != "atob" && params.Dir != "btoa" {
203 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
204 }
205
206 return params, nil
207}
208
209// parseRequestBody parses JSON request body and direction
210func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
211 var jsonData any
212 if err := c.BodyParser(&jsonData); err != nil {
213 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
214 }
215
216 direction, err := mapper.ParseDirection(dir)
217 if err != nil {
218 return nil, mapper.BtoA, err
219 }
220
221 return jsonData, direction, nil
222}
223
Akron49ceeb42025-05-23 17:46:01 +0200224func main() {
225 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200226 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200227
Akrone1cff7c2025-06-04 18:43:32 +0200228 // Validate command line arguments
229 if cfg.Config == "" && len(cfg.Mappings) == 0 {
230 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
231 }
232
Akron14678dc2025-06-05 13:01:38 +0200233 // Expand glob patterns in mapping files
234 expandedMappings, err := expandGlobs(cfg.Mappings)
235 if err != nil {
236 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
237 }
238
Akrone1cff7c2025-06-04 18:43:32 +0200239 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200240 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200241 if err != nil {
242 log.Fatal().Err(err).Msg("Failed to load configuration")
243 }
244
Akrona8a66ce2025-06-05 10:50:17 +0200245 finalPort := yamlConfig.Port
246 finalLogLevel := yamlConfig.LogLevel
247
248 // Use command line values if provided (they override config file)
249 if cfg.Port != nil {
250 finalPort = *cfg.Port
251 }
252 if cfg.LogLevel != nil {
253 finalLogLevel = *cfg.LogLevel
254 }
255
256 // Set up logging with the final log level
257 setupLogger(finalLogLevel)
258
Akron49ceeb42025-05-23 17:46:01 +0200259 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200260 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200261 if err != nil {
262 log.Fatal().Err(err).Msg("Failed to create mapper")
263 }
264
265 // Create fiber app
266 app := fiber.New(fiber.Config{
267 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200268 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200269 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
270 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200271 })
272
Akron3caee162025-07-01 17:44:58 +0200273 // Add zerolog-integrated logger middleware
274 app.Use(setupFiberLogger())
275
Akron49ceeb42025-05-23 17:46:01 +0200276 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200277 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200278
279 // Start server
280 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200281 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100282 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200283
284 for _, list := range yamlConfig.Lists {
285 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100286 fmt.Printf("Loaded mapping desc=%s id=%s\n",
287 formatConsoleField(list.Description),
288 list.ID,
289 )
Akronae3ffde2025-06-05 14:04:06 +0200290 }
291
Akrona8a66ce2025-06-05 10:50:17 +0200292 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200293 log.Fatal().Err(err).Msg("Server error")
294 }
295 }()
296
297 // Wait for interrupt signal
298 sigChan := make(chan os.Signal, 1)
299 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
300 <-sigChan
301
302 // Graceful shutdown
303 log.Info().Msg("Shutting down server")
304 if err := app.Shutdown(); err != nil {
305 log.Error().Err(err).Msg("Error during shutdown")
306 }
307}
308
Akron06d21f02025-06-04 14:36:07 +0200309func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100310 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
311 pluginTmpl := texttemplate.Must(texttemplate.ParseFS(staticFS, "static/plugin.html"))
312
Akron49ceeb42025-05-23 17:46:01 +0200313 // Health check endpoint
314 app.Get("/health", func(c *fiber.Ctx) error {
315 return c.SendString("OK")
316 })
317
Akrond8a76b32026-02-20 09:31:56 +0100318 // Static file serving from embedded FS
319 app.Get("/static/*", handleStaticFile())
320
Akronbf73a122026-02-27 15:02:16 +0100321 // Composite cascade transformation endpoints (cfg in path)
322 app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig.Lists))
323 app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig.Lists))
Akron512aab62026-02-20 08:36:12 +0100324
Akron49ceeb42025-05-23 17:46:01 +0200325 // Transformation endpoint
Akron8414ae52026-05-19 13:31:14 +0200326 app.Post("/:map/query", handleTransform(m, yamlConfig.Lists))
Akron40aaa632025-06-03 17:57:52 +0200327
Akron4de47a92025-06-27 11:58:11 +0200328 // Response transformation endpoint
Akron8414ae52026-05-19 13:31:14 +0200329 app.Post("/:map/response", handleResponseTransform(m, yamlConfig.Lists))
Akron4de47a92025-06-27 11:58:11 +0200330
Akron40aaa632025-06-03 17:57:52 +0200331 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100332 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
333 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
334}
335
336func handleStaticFile() fiber.Handler {
337 return func(c *fiber.Ctx) error {
338 name := c.Params("*")
339 data, err := fs.ReadFile(staticFS, "static/"+name)
340 if err != nil {
341 return c.Status(fiber.StatusNotFound).SendString("not found")
342 }
343 switch {
344 case strings.HasSuffix(name, ".js"):
345 c.Set("Content-Type", "text/javascript; charset=utf-8")
346 case strings.HasSuffix(name, ".css"):
347 c.Set("Content-Type", "text/css; charset=utf-8")
348 case strings.HasSuffix(name, ".html"):
349 c.Set("Content-Type", "text/html; charset=utf-8")
350 }
351 return c.Send(data)
352 }
353}
354
355func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
356 return BasePageData{
357 Title: config.Title,
358 Version: config.Version,
359 Hash: config.Buildhash,
360 Date: config.Buildtime,
361 Description: config.Description,
362 Server: yamlConfig.Server,
363 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100364 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100365 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100366 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100367 }
368}
369
370func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
371 data := ConfigPageData{
372 BasePageData: buildBasePageData(yamlConfig),
373 }
374
375 for _, list := range yamlConfig.Lists {
376 normalized := list
377 if normalized.Type == "" {
378 normalized.Type = "annotation"
379 }
380 if list.IsCorpus() {
381 data.CorpusMappings = append(data.CorpusMappings, normalized)
382 } else {
383 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
384 }
385 }
Akron247a93a2026-02-20 16:28:40 +0100386
387 data.MappingSections = []MappingSectionData{
388 {
Akron8bdf5202026-02-24 10:01:15 +0100389 Title: "Request",
390 Mode: "request",
391 CheckboxClass: "request-cb",
392 CheckboxName: "request",
393 FieldsClass: "request-fields",
394 ArrowClass: "request-dir-arrow",
395 ArrowDirection: "atob",
396 ArrowLabel: "\u2192",
Akron247a93a2026-02-20 16:28:40 +0100397 },
398 {
Akron8bdf5202026-02-24 10:01:15 +0100399 Title: "Response",
400 Mode: "response",
401 CheckboxClass: "response-cb",
402 CheckboxName: "response",
403 FieldsClass: "response-fields",
404 ArrowClass: "response-dir-arrow",
405 ArrowDirection: "btoa",
406 ArrowLabel: "\u2190",
Akron247a93a2026-02-20 16:28:40 +0100407 },
408 }
409
Akrond8a76b32026-02-20 09:31:56 +0100410 return data
Akron49ceeb42025-05-23 17:46:01 +0200411}
412
Akron512aab62026-02-20 08:36:12 +0100413func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
Akron8414ae52026-05-19 13:31:14 +0200414 listsByID := make(map[string]*config.MappingList, len(lists))
415 for i := range lists {
416 listsByID[lists[i].ID] = &lists[i]
417 }
418
Akron512aab62026-02-20 08:36:12 +0100419 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100420 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100421 if len(cfgRaw) > maxParamLength {
422 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
423 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
424 })
425 }
426
427 var jsonData any
428 if err := c.BodyParser(&jsonData); err != nil {
429 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
430 "error": "invalid JSON in request body",
431 })
432 }
433
434 entries, err := ParseCfgParam(cfgRaw, lists)
435 if err != nil {
436 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
437 "error": err.Error(),
438 })
439 }
440
441 if len(entries) == 0 {
442 return c.JSON(jsonData)
443 }
444
Akron8414ae52026-05-19 13:31:14 +0200445 rewrites := c.Query("rewrites", "")
446 var rewritesOverride *bool
447 if rewrites != "" {
448 v := rewrites == "true"
449 rewritesOverride = &v
450 }
451
Akron512aab62026-02-20 08:36:12 +0100452 orderedIDs := make([]string, 0, len(entries))
453 opts := make([]mapper.MappingOptions, 0, len(entries))
454 for _, entry := range entries {
455 dir := mapper.AtoB
456 if entry.Direction == "btoa" {
457 dir = mapper.BtoA
458 }
459
Akron8414ae52026-05-19 13:31:14 +0200460 addRewrites := false
461 if list, ok := listsByID[entry.ID]; ok {
462 addRewrites = list.Rewrites
463 }
464 if rewritesOverride != nil {
465 addRewrites = *rewritesOverride
466 }
467
Akron512aab62026-02-20 08:36:12 +0100468 orderedIDs = append(orderedIDs, entry.ID)
469 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200470 Direction: dir,
471 FoundryA: entry.FoundryA,
472 LayerA: entry.LayerA,
473 FoundryB: entry.FoundryB,
474 LayerB: entry.LayerB,
475 FieldA: entry.FieldA,
476 FieldB: entry.FieldB,
477 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100478 })
479 }
480
481 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
482 if err != nil {
483 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
484 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
485 "error": err.Error(),
486 })
487 }
488
489 return c.JSON(result)
490 }
491}
492
493func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
Akron8414ae52026-05-19 13:31:14 +0200494 listsByID := make(map[string]*config.MappingList, len(lists))
495 for i := range lists {
496 listsByID[lists[i].ID] = &lists[i]
497 }
498
Akron512aab62026-02-20 08:36:12 +0100499 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100500 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100501 if len(cfgRaw) > maxParamLength {
502 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
503 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
504 })
505 }
506
507 var jsonData any
508 if err := c.BodyParser(&jsonData); err != nil {
509 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
510 "error": "invalid JSON in request body",
511 })
512 }
513
514 entries, err := ParseCfgParam(cfgRaw, lists)
515 if err != nil {
516 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
517 "error": err.Error(),
518 })
519 }
520
521 if len(entries) == 0 {
522 return c.JSON(jsonData)
523 }
524
Akron8414ae52026-05-19 13:31:14 +0200525 rewrites := c.Query("rewrites", "")
526 var rewritesOverride *bool
527 if rewrites != "" {
528 v := rewrites == "true"
529 rewritesOverride = &v
530 }
531
Akron512aab62026-02-20 08:36:12 +0100532 orderedIDs := make([]string, 0, len(entries))
533 opts := make([]mapper.MappingOptions, 0, len(entries))
534 for _, entry := range entries {
535 dir := mapper.AtoB
536 if entry.Direction == "btoa" {
537 dir = mapper.BtoA
538 }
539
Akron8414ae52026-05-19 13:31:14 +0200540 addRewrites := false
541 if list, ok := listsByID[entry.ID]; ok {
542 addRewrites = list.Rewrites
543 }
544 if rewritesOverride != nil {
545 addRewrites = *rewritesOverride
546 }
547
Akron512aab62026-02-20 08:36:12 +0100548 orderedIDs = append(orderedIDs, entry.ID)
549 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200550 Direction: dir,
551 FoundryA: entry.FoundryA,
552 LayerA: entry.LayerA,
553 FoundryB: entry.FoundryB,
554 LayerB: entry.LayerB,
555 FieldA: entry.FieldA,
556 FieldB: entry.FieldB,
557 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100558 })
559 }
560
561 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
562 if err != nil {
563 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
564 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
565 "error": err.Error(),
566 })
567 }
568
569 return c.JSON(result)
570 }
571}
572
Akron8414ae52026-05-19 13:31:14 +0200573func handleTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
574 listsByID := make(map[string]*config.MappingList, len(lists))
575 for i := range lists {
576 listsByID[lists[i].ID] = &lists[i]
577 }
578
Akron49ceeb42025-05-23 17:46:01 +0200579 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200580 // Extract and validate parameters
581 params, err := extractRequestParams(c)
582 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200583 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
584 "error": err.Error(),
585 })
586 }
587
Akron49ceeb42025-05-23 17:46:01 +0200588 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200589 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200590 if err != nil {
591 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
592 "error": err.Error(),
593 })
594 }
595
Akron8414ae52026-05-19 13:31:14 +0200596 // Determine rewrites: query param overrides YAML default
597 addRewrites := false
598 if list, ok := listsByID[params.MapID]; ok {
599 addRewrites = list.Rewrites
600 }
601 if params.Rewrites != nil {
602 addRewrites = *params.Rewrites
603 }
604
Akron49ceeb42025-05-23 17:46:01 +0200605 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200606 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200607 Direction: direction,
608 FoundryA: params.FoundryA,
609 FoundryB: params.FoundryB,
610 LayerA: params.LayerA,
611 LayerB: params.LayerB,
612 AddRewrites: addRewrites,
Akron49ceeb42025-05-23 17:46:01 +0200613 }, jsonData)
614
615 if err != nil {
616 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200617 Str("mapID", params.MapID).
618 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200619 Msg("Failed to apply mappings")
620
621 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
622 "error": err.Error(),
623 })
624 }
625
626 return c.JSON(result)
627 }
628}
Akron74e1c072025-05-26 14:38:25 +0200629
Akron8414ae52026-05-19 13:31:14 +0200630func handleResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
631 listsByID := make(map[string]*config.MappingList, len(lists))
632 for i := range lists {
633 listsByID[lists[i].ID] = &lists[i]
634 }
635
Akron4de47a92025-06-27 11:58:11 +0200636 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200637 // Extract and validate parameters
638 params, err := extractRequestParams(c)
639 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200640 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
641 "error": err.Error(),
642 })
643 }
644
Akron4de47a92025-06-27 11:58:11 +0200645 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200646 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200647 if err != nil {
648 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
649 "error": err.Error(),
650 })
651 }
652
Akron8414ae52026-05-19 13:31:14 +0200653 // Determine rewrites: query param overrides YAML default
654 addRewrites := false
655 if list, ok := listsByID[params.MapID]; ok {
656 addRewrites = list.Rewrites
657 }
658 if params.Rewrites != nil {
659 addRewrites = *params.Rewrites
660 }
661
Akron4de47a92025-06-27 11:58:11 +0200662 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200663 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200664 Direction: direction,
665 FoundryA: params.FoundryA,
666 FoundryB: params.FoundryB,
667 LayerA: params.LayerA,
668 LayerB: params.LayerB,
669 AddRewrites: addRewrites,
Akron4de47a92025-06-27 11:58:11 +0200670 }, jsonData)
671
672 if err != nil {
673 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200674 Str("mapID", params.MapID).
675 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200676 Msg("Failed to apply response mappings")
677
678 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
679 "error": err.Error(),
680 })
681 }
682
683 return c.JSON(result)
684 }
685}
686
Akron74e1c072025-05-26 14:38:25 +0200687// validateInput checks if the input parameters are valid
688func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200689 // Define parameter checks
690 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200691 name string
692 value string
693 }{
694 {"mapID", mapID},
695 {"dir", dir},
696 {"foundryA", foundryA},
697 {"foundryB", foundryB},
698 {"layerA", layerA},
699 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200700 }
701
702 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200703 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200704 if len(param.value) > maxParamLength {
705 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
706 }
Akron74e1c072025-05-26 14:38:25 +0200707 if strings.ContainsAny(param.value, "<>{}[]\\") {
708 return fmt.Errorf("%s contains invalid characters", param.name)
709 }
710 }
711
Akron69d43bf2025-05-26 17:09:00 +0200712 if len(body) > maxInputLength {
713 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
714 }
715
Akron74e1c072025-05-26 14:38:25 +0200716 return nil
717}
Akron40aaa632025-06-03 17:57:52 +0200718
Akrond8a76b32026-02-20 09:31:56 +0100719func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *texttemplate.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200720 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200721 mapID := c.Params("map")
722
Akrond8a76b32026-02-20 09:31:56 +0100723 // Config page (GET /)
724 if mapID == "" {
725 data := buildConfigPageData(yamlConfig)
726 var buf bytes.Buffer
727 if err := configTmpl.Execute(&buf, data); err != nil {
728 log.Error().Err(err).Msg("Failed to execute config template")
729 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
730 }
731 c.Set("Content-Type", "text/html")
732 return c.Send(buf.Bytes())
733 }
734
735 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200736 // Get query parameters
737 dir := c.Query("dir", "atob")
738 foundryA := c.Query("foundryA", "")
739 foundryB := c.Query("foundryB", "")
740 layerA := c.Query("layerA", "")
741 layerB := c.Query("layerB", "")
742
Akron49b525c2025-07-03 15:17:06 +0200743 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200744 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
745 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
746 "error": err.Error(),
747 })
748 }
749
Akroncb51f812025-06-30 15:24:20 +0200750 if dir != "atob" && dir != "btoa" {
751 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
752 "error": "invalid direction, must be 'atob' or 'btoa'",
753 })
754 }
755
Akroncb51f812025-06-30 15:24:20 +0200756 queryParams := QueryParams{
757 Dir: dir,
758 FoundryA: foundryA,
759 FoundryB: foundryB,
760 LayerA: layerA,
761 LayerB: layerB,
762 }
763
Akrond8a76b32026-02-20 09:31:56 +0100764 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
765 if err != nil {
766 log.Warn().Err(err).Msg("Failed to build query service URL")
767 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
768 }
769 reversed := queryParams
770 if queryParams.Dir == "btoa" {
771 reversed.Dir = "atob"
772 } else {
773 reversed.Dir = "btoa"
774 }
775 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
776 if err != nil {
777 log.Warn().Err(err).Msg("Failed to build response service URL")
778 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
779 }
Akron40aaa632025-06-03 17:57:52 +0200780
Akrond8a76b32026-02-20 09:31:56 +0100781 data := SingleMappingPageData{
782 BasePageData: buildBasePageData(yamlConfig),
783 MapID: mapID,
784 Mappings: yamlConfig.Lists,
785 QueryURL: queryURL,
786 ResponseURL: responseURL,
787 }
788
789 var buf bytes.Buffer
790 if err := pluginTmpl.Execute(&buf, data); err != nil {
791 log.Error().Err(err).Msg("Failed to execute plugin template")
792 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
793 }
Akron40aaa632025-06-03 17:57:52 +0200794 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100795 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200796 }
797}
798
Akrond8a76b32026-02-20 09:31:56 +0100799func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
800 service, err := url.Parse(serviceURL)
801 if err != nil {
802 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200803 }
Akrond8a76b32026-02-20 09:31:56 +0100804 service.Path = path.Join(service.Path, mapID, endpoint)
805 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
806 return service.String(), nil
807}
Akronc376dcc2025-06-04 17:00:18 +0200808
Akrond8a76b32026-02-20 09:31:56 +0100809func formatConsoleField(value string) string {
810 if strings.ContainsAny(value, " \t") {
811 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200812 }
Akrond8a76b32026-02-20 09:31:56 +0100813 return value
Akron40aaa632025-06-03 17:57:52 +0200814}
Akron14678dc2025-06-05 13:01:38 +0200815
Akroncb51f812025-06-30 15:24:20 +0200816// buildQueryParams builds a query string from the provided parameters
817func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
818 params := url.Values{}
819 if dir != "" {
820 params.Add("dir", dir)
821 }
822 if foundryA != "" {
823 params.Add("foundryA", foundryA)
824 }
825 if foundryB != "" {
826 params.Add("foundryB", foundryB)
827 }
828 if layerA != "" {
829 params.Add("layerA", layerA)
830 }
831 if layerB != "" {
832 params.Add("layerB", layerB)
833 }
834 return params.Encode()
835}
836
Akron14678dc2025-06-05 13:01:38 +0200837// expandGlobs expands glob patterns in the slice of file paths
838// Returns the expanded list of files or an error if glob expansion fails
839func expandGlobs(patterns []string) ([]string, error) {
840 var expanded []string
841
842 for _, pattern := range patterns {
843 // Use filepath.Glob which works cross-platform
844 matches, err := filepath.Glob(pattern)
845 if err != nil {
846 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
847 }
848
849 // If no matches found, treat as literal filename (consistent with shell behavior)
850 if len(matches) == 0 {
851 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
852 expanded = append(expanded, pattern)
853 } else {
854 expanded = append(expanded, matches...)
855 }
856 }
857
858 return expanded, nil
859}