blob: 732cc2b8350d67914f12f9c0850a2d0ecf2bd014 [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
80}
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
190 // Validate input parameters
191 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
192 return nil, err
193 }
194
195 // Validate direction
196 if params.Dir != "atob" && params.Dir != "btoa" {
197 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
198 }
199
200 return params, nil
201}
202
203// parseRequestBody parses JSON request body and direction
204func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
205 var jsonData any
206 if err := c.BodyParser(&jsonData); err != nil {
207 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
208 }
209
210 direction, err := mapper.ParseDirection(dir)
211 if err != nil {
212 return nil, mapper.BtoA, err
213 }
214
215 return jsonData, direction, nil
216}
217
Akron49ceeb42025-05-23 17:46:01 +0200218func main() {
219 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200220 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200221
Akrone1cff7c2025-06-04 18:43:32 +0200222 // Validate command line arguments
223 if cfg.Config == "" && len(cfg.Mappings) == 0 {
224 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
225 }
226
Akron14678dc2025-06-05 13:01:38 +0200227 // Expand glob patterns in mapping files
228 expandedMappings, err := expandGlobs(cfg.Mappings)
229 if err != nil {
230 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
231 }
232
Akrone1cff7c2025-06-04 18:43:32 +0200233 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200234 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200235 if err != nil {
236 log.Fatal().Err(err).Msg("Failed to load configuration")
237 }
238
Akrona8a66ce2025-06-05 10:50:17 +0200239 finalPort := yamlConfig.Port
240 finalLogLevel := yamlConfig.LogLevel
241
242 // Use command line values if provided (they override config file)
243 if cfg.Port != nil {
244 finalPort = *cfg.Port
245 }
246 if cfg.LogLevel != nil {
247 finalLogLevel = *cfg.LogLevel
248 }
249
250 // Set up logging with the final log level
251 setupLogger(finalLogLevel)
252
Akron49ceeb42025-05-23 17:46:01 +0200253 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200254 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200255 if err != nil {
256 log.Fatal().Err(err).Msg("Failed to create mapper")
257 }
258
259 // Create fiber app
260 app := fiber.New(fiber.Config{
261 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200262 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200263 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
264 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200265 })
266
Akron3caee162025-07-01 17:44:58 +0200267 // Add zerolog-integrated logger middleware
268 app.Use(setupFiberLogger())
269
Akron49ceeb42025-05-23 17:46:01 +0200270 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200271 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200272
273 // Start server
274 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200275 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100276 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200277
278 for _, list := range yamlConfig.Lists {
279 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100280 fmt.Printf("Loaded mapping desc=%s id=%s\n",
281 formatConsoleField(list.Description),
282 list.ID,
283 )
Akronae3ffde2025-06-05 14:04:06 +0200284 }
285
Akrona8a66ce2025-06-05 10:50:17 +0200286 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200287 log.Fatal().Err(err).Msg("Server error")
288 }
289 }()
290
291 // Wait for interrupt signal
292 sigChan := make(chan os.Signal, 1)
293 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
294 <-sigChan
295
296 // Graceful shutdown
297 log.Info().Msg("Shutting down server")
298 if err := app.Shutdown(); err != nil {
299 log.Error().Err(err).Msg("Error during shutdown")
300 }
301}
302
Akron06d21f02025-06-04 14:36:07 +0200303func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100304 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
305 pluginTmpl := texttemplate.Must(texttemplate.ParseFS(staticFS, "static/plugin.html"))
306
Akron49ceeb42025-05-23 17:46:01 +0200307 // Health check endpoint
308 app.Get("/health", func(c *fiber.Ctx) error {
309 return c.SendString("OK")
310 })
311
Akrond8a76b32026-02-20 09:31:56 +0100312 // Static file serving from embedded FS
313 app.Get("/static/*", handleStaticFile())
314
Akron512aab62026-02-20 08:36:12 +0100315 // Composite cascade transformation endpoints
316 app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
317 app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
318
Akron49ceeb42025-05-23 17:46:01 +0200319 // Transformation endpoint
320 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200321
Akron4de47a92025-06-27 11:58:11 +0200322 // Response transformation endpoint
323 app.Post("/:map/response", handleResponseTransform(m))
324
Akron40aaa632025-06-03 17:57:52 +0200325 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100326 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
327 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
328}
329
330func handleStaticFile() fiber.Handler {
331 return func(c *fiber.Ctx) error {
332 name := c.Params("*")
333 data, err := fs.ReadFile(staticFS, "static/"+name)
334 if err != nil {
335 return c.Status(fiber.StatusNotFound).SendString("not found")
336 }
337 switch {
338 case strings.HasSuffix(name, ".js"):
339 c.Set("Content-Type", "text/javascript; charset=utf-8")
340 case strings.HasSuffix(name, ".css"):
341 c.Set("Content-Type", "text/css; charset=utf-8")
342 case strings.HasSuffix(name, ".html"):
343 c.Set("Content-Type", "text/html; charset=utf-8")
344 }
345 return c.Send(data)
346 }
347}
348
349func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
350 return BasePageData{
351 Title: config.Title,
352 Version: config.Version,
353 Hash: config.Buildhash,
354 Date: config.Buildtime,
355 Description: config.Description,
356 Server: yamlConfig.Server,
357 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100358 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100359 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100360 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100361 }
362}
363
364func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
365 data := ConfigPageData{
366 BasePageData: buildBasePageData(yamlConfig),
367 }
368
369 for _, list := range yamlConfig.Lists {
370 normalized := list
371 if normalized.Type == "" {
372 normalized.Type = "annotation"
373 }
374 if list.IsCorpus() {
375 data.CorpusMappings = append(data.CorpusMappings, normalized)
376 } else {
377 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
378 }
379 }
Akron247a93a2026-02-20 16:28:40 +0100380
381 data.MappingSections = []MappingSectionData{
382 {
Akron8bdf5202026-02-24 10:01:15 +0100383 Title: "Request",
384 Mode: "request",
385 CheckboxClass: "request-cb",
386 CheckboxName: "request",
387 FieldsClass: "request-fields",
388 ArrowClass: "request-dir-arrow",
389 ArrowDirection: "atob",
390 ArrowLabel: "\u2192",
Akron247a93a2026-02-20 16:28:40 +0100391 },
392 {
Akron8bdf5202026-02-24 10:01:15 +0100393 Title: "Response",
394 Mode: "response",
395 CheckboxClass: "response-cb",
396 CheckboxName: "response",
397 FieldsClass: "response-fields",
398 ArrowClass: "response-dir-arrow",
399 ArrowDirection: "btoa",
400 ArrowLabel: "\u2190",
Akron247a93a2026-02-20 16:28:40 +0100401 },
402 }
403
Akrond8a76b32026-02-20 09:31:56 +0100404 return data
Akron49ceeb42025-05-23 17:46:01 +0200405}
406
Akron512aab62026-02-20 08:36:12 +0100407func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
408 return func(c *fiber.Ctx) error {
409 cfgRaw := c.Query("cfg", "")
410 if len(cfgRaw) > maxParamLength {
411 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
412 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
413 })
414 }
415
416 var jsonData any
417 if err := c.BodyParser(&jsonData); err != nil {
418 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
419 "error": "invalid JSON in request body",
420 })
421 }
422
423 entries, err := ParseCfgParam(cfgRaw, lists)
424 if err != nil {
425 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
426 "error": err.Error(),
427 })
428 }
429
430 if len(entries) == 0 {
431 return c.JSON(jsonData)
432 }
433
434 orderedIDs := make([]string, 0, len(entries))
435 opts := make([]mapper.MappingOptions, 0, len(entries))
436 for _, entry := range entries {
437 dir := mapper.AtoB
438 if entry.Direction == "btoa" {
439 dir = mapper.BtoA
440 }
441
442 orderedIDs = append(orderedIDs, entry.ID)
443 opts = append(opts, mapper.MappingOptions{
444 Direction: dir,
445 FoundryA: entry.FoundryA,
446 LayerA: entry.LayerA,
447 FoundryB: entry.FoundryB,
448 LayerB: entry.LayerB,
Akron41310262026-02-23 18:58:53 +0100449 FieldA: entry.FieldA,
450 FieldB: entry.FieldB,
Akron512aab62026-02-20 08:36:12 +0100451 })
452 }
453
454 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
455 if err != nil {
456 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
457 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
458 "error": err.Error(),
459 })
460 }
461
462 return c.JSON(result)
463 }
464}
465
466func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
467 return func(c *fiber.Ctx) error {
468 cfgRaw := c.Query("cfg", "")
469 if len(cfgRaw) > maxParamLength {
470 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
471 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
472 })
473 }
474
475 var jsonData any
476 if err := c.BodyParser(&jsonData); err != nil {
477 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
478 "error": "invalid JSON in request body",
479 })
480 }
481
482 entries, err := ParseCfgParam(cfgRaw, lists)
483 if err != nil {
484 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
485 "error": err.Error(),
486 })
487 }
488
489 if len(entries) == 0 {
490 return c.JSON(jsonData)
491 }
492
493 orderedIDs := make([]string, 0, len(entries))
494 opts := make([]mapper.MappingOptions, 0, len(entries))
495 for _, entry := range entries {
496 dir := mapper.AtoB
497 if entry.Direction == "btoa" {
498 dir = mapper.BtoA
499 }
500
501 orderedIDs = append(orderedIDs, entry.ID)
502 opts = append(opts, mapper.MappingOptions{
503 Direction: dir,
504 FoundryA: entry.FoundryA,
505 LayerA: entry.LayerA,
506 FoundryB: entry.FoundryB,
507 LayerB: entry.LayerB,
Akron41310262026-02-23 18:58:53 +0100508 FieldA: entry.FieldA,
509 FieldB: entry.FieldB,
Akron512aab62026-02-20 08:36:12 +0100510 })
511 }
512
513 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
514 if err != nil {
515 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
516 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
517 "error": err.Error(),
518 })
519 }
520
521 return c.JSON(result)
522 }
523}
524
Akron49ceeb42025-05-23 17:46:01 +0200525func handleTransform(m *mapper.Mapper) fiber.Handler {
526 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200527 // Extract and validate parameters
528 params, err := extractRequestParams(c)
529 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200530 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
531 "error": err.Error(),
532 })
533 }
534
Akron49ceeb42025-05-23 17:46:01 +0200535 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200536 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200537 if err != nil {
538 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
539 "error": err.Error(),
540 })
541 }
542
Akron49ceeb42025-05-23 17:46:01 +0200543 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200544 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200545 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200546 FoundryA: params.FoundryA,
547 FoundryB: params.FoundryB,
548 LayerA: params.LayerA,
549 LayerB: params.LayerB,
Akron49ceeb42025-05-23 17:46:01 +0200550 }, jsonData)
551
552 if err != nil {
553 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200554 Str("mapID", params.MapID).
555 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200556 Msg("Failed to apply mappings")
557
558 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
559 "error": err.Error(),
560 })
561 }
562
563 return c.JSON(result)
564 }
565}
Akron74e1c072025-05-26 14:38:25 +0200566
Akron4de47a92025-06-27 11:58:11 +0200567func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
568 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200569 // Extract and validate parameters
570 params, err := extractRequestParams(c)
571 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200572 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
573 "error": err.Error(),
574 })
575 }
576
Akron4de47a92025-06-27 11:58:11 +0200577 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200578 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200579 if err != nil {
580 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
581 "error": err.Error(),
582 })
583 }
584
585 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200586 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron4de47a92025-06-27 11:58:11 +0200587 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200588 FoundryA: params.FoundryA,
589 FoundryB: params.FoundryB,
590 LayerA: params.LayerA,
591 LayerB: params.LayerB,
Akron4de47a92025-06-27 11:58:11 +0200592 }, jsonData)
593
594 if err != nil {
595 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200596 Str("mapID", params.MapID).
597 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200598 Msg("Failed to apply response mappings")
599
600 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
601 "error": err.Error(),
602 })
603 }
604
605 return c.JSON(result)
606 }
607}
608
Akron74e1c072025-05-26 14:38:25 +0200609// validateInput checks if the input parameters are valid
610func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200611 // Define parameter checks
612 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200613 name string
614 value string
615 }{
616 {"mapID", mapID},
617 {"dir", dir},
618 {"foundryA", foundryA},
619 {"foundryB", foundryB},
620 {"layerA", layerA},
621 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200622 }
623
624 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200625 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200626 if len(param.value) > maxParamLength {
627 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
628 }
Akron74e1c072025-05-26 14:38:25 +0200629 if strings.ContainsAny(param.value, "<>{}[]\\") {
630 return fmt.Errorf("%s contains invalid characters", param.name)
631 }
632 }
633
Akron69d43bf2025-05-26 17:09:00 +0200634 if len(body) > maxInputLength {
635 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
636 }
637
Akron74e1c072025-05-26 14:38:25 +0200638 return nil
639}
Akron40aaa632025-06-03 17:57:52 +0200640
Akrond8a76b32026-02-20 09:31:56 +0100641func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *texttemplate.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200642 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200643 mapID := c.Params("map")
644
Akrond8a76b32026-02-20 09:31:56 +0100645 // Config page (GET /)
646 if mapID == "" {
647 data := buildConfigPageData(yamlConfig)
648 var buf bytes.Buffer
649 if err := configTmpl.Execute(&buf, data); err != nil {
650 log.Error().Err(err).Msg("Failed to execute config template")
651 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
652 }
653 c.Set("Content-Type", "text/html")
654 return c.Send(buf.Bytes())
655 }
656
657 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200658 // Get query parameters
659 dir := c.Query("dir", "atob")
660 foundryA := c.Query("foundryA", "")
661 foundryB := c.Query("foundryB", "")
662 layerA := c.Query("layerA", "")
663 layerB := c.Query("layerB", "")
664
Akron49b525c2025-07-03 15:17:06 +0200665 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200666 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
667 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
668 "error": err.Error(),
669 })
670 }
671
Akroncb51f812025-06-30 15:24:20 +0200672 if dir != "atob" && dir != "btoa" {
673 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
674 "error": "invalid direction, must be 'atob' or 'btoa'",
675 })
676 }
677
Akroncb51f812025-06-30 15:24:20 +0200678 queryParams := QueryParams{
679 Dir: dir,
680 FoundryA: foundryA,
681 FoundryB: foundryB,
682 LayerA: layerA,
683 LayerB: layerB,
684 }
685
Akrond8a76b32026-02-20 09:31:56 +0100686 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
687 if err != nil {
688 log.Warn().Err(err).Msg("Failed to build query service URL")
689 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
690 }
691 reversed := queryParams
692 if queryParams.Dir == "btoa" {
693 reversed.Dir = "atob"
694 } else {
695 reversed.Dir = "btoa"
696 }
697 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
698 if err != nil {
699 log.Warn().Err(err).Msg("Failed to build response service URL")
700 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
701 }
Akron40aaa632025-06-03 17:57:52 +0200702
Akrond8a76b32026-02-20 09:31:56 +0100703 data := SingleMappingPageData{
704 BasePageData: buildBasePageData(yamlConfig),
705 MapID: mapID,
706 Mappings: yamlConfig.Lists,
707 QueryURL: queryURL,
708 ResponseURL: responseURL,
709 }
710
711 var buf bytes.Buffer
712 if err := pluginTmpl.Execute(&buf, data); err != nil {
713 log.Error().Err(err).Msg("Failed to execute plugin template")
714 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
715 }
Akron40aaa632025-06-03 17:57:52 +0200716 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100717 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200718 }
719}
720
Akrond8a76b32026-02-20 09:31:56 +0100721func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
722 service, err := url.Parse(serviceURL)
723 if err != nil {
724 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200725 }
Akrond8a76b32026-02-20 09:31:56 +0100726 service.Path = path.Join(service.Path, mapID, endpoint)
727 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
728 return service.String(), nil
729}
Akronc376dcc2025-06-04 17:00:18 +0200730
Akrond8a76b32026-02-20 09:31:56 +0100731func formatConsoleField(value string) string {
732 if strings.ContainsAny(value, " \t") {
733 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200734 }
Akrond8a76b32026-02-20 09:31:56 +0100735 return value
Akron40aaa632025-06-03 17:57:52 +0200736}
Akron14678dc2025-06-05 13:01:38 +0200737
Akroncb51f812025-06-30 15:24:20 +0200738// buildQueryParams builds a query string from the provided parameters
739func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
740 params := url.Values{}
741 if dir != "" {
742 params.Add("dir", dir)
743 }
744 if foundryA != "" {
745 params.Add("foundryA", foundryA)
746 }
747 if foundryB != "" {
748 params.Add("foundryB", foundryB)
749 }
750 if layerA != "" {
751 params.Add("layerA", layerA)
752 }
753 if layerB != "" {
754 params.Add("layerB", layerB)
755 }
756 return params.Encode()
757}
758
Akron14678dc2025-06-05 13:01:38 +0200759// expandGlobs expands glob patterns in the slice of file paths
760// Returns the expanded list of files or an error if glob expansion fails
761func expandGlobs(patterns []string) ([]string, error) {
762 var expanded []string
763
764 for _, pattern := range patterns {
765 // Use filepath.Glob which works cross-platform
766 matches, err := filepath.Glob(pattern)
767 if err != nil {
768 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
769 }
770
771 // If no matches found, treat as literal filename (consistent with shell behavior)
772 if len(matches) == 0 {
773 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
774 expanded = append(expanded, pattern)
775 } else {
776 expanded = append(expanded, matches...)
777 }
778 }
779
780 return expanded, nil
781}