blob: 0e5044bb9825bc2355151786067720211f03d064 [file] [log] [blame]
Akron49ceeb42025-05-23 17:46:01 +02001package main
2
3import (
Akron49ceeb42025-05-23 17:46:01 +02004 "fmt"
Akron80067202025-06-06 14:16:25 +02005 "net/url"
Akron49ceeb42025-05-23 17:46:01 +02006 "os"
7 "os/signal"
Akron80067202025-06-06 14:16:25 +02008 "path"
Akron14678dc2025-06-05 13:01:38 +02009 "path/filepath"
Akron49ceeb42025-05-23 17:46:01 +020010 "strings"
11 "syscall"
Akron3caee162025-07-01 17:44:58 +020012 "time"
Akron49ceeb42025-05-23 17:46:01 +020013
Akron2ef703c2025-07-03 15:57:42 +020014 "github.com/KorAP/Koral-Mapper/config"
15 "github.com/KorAP/Koral-Mapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020016 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020017 "github.com/gofiber/fiber/v2"
18 "github.com/rs/zerolog"
19 "github.com/rs/zerolog/log"
20)
21
Akron74e1c072025-05-26 14:38:25 +020022const (
23 maxInputLength = 1024 * 1024 // 1MB
24 maxParamLength = 1024 // 1KB
25)
26
Akrona00d4752025-05-26 17:34:36 +020027type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020028 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020029 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020030 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020031 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020032}
33
Akrondab27112025-06-05 13:52:43 +020034type TemplateMapping struct {
35 ID string
36 Description string
37}
38
Akron40aaa632025-06-03 17:57:52 +020039// TemplateData holds data for the Kalamar plugin template
40type TemplateData struct {
41 Title string
42 Version string
Akronfc77b5e2025-06-04 11:44:43 +020043 Hash string
44 Date string
Akron40aaa632025-06-03 17:57:52 +020045 Description string
Akron06d21f02025-06-04 14:36:07 +020046 Server string
47 SDK string
Akron2ac2ec02025-06-05 15:26:42 +020048 ServiceURL string
Akronc376dcc2025-06-04 17:00:18 +020049 MapID string
Akrondab27112025-06-05 13:52:43 +020050 Mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +020051}
52
Akroncb51f812025-06-30 15:24:20 +020053type QueryParams struct {
54 Dir string
55 FoundryA string
56 FoundryB string
57 LayerA string
58 LayerB string
59}
60
Akron49b525c2025-07-03 15:17:06 +020061// requestParams holds common request parameters
62type requestParams struct {
63 MapID string
64 Dir string
65 FoundryA string
66 FoundryB string
67 LayerA string
68 LayerB string
69}
70
Akrona00d4752025-05-26 17:34:36 +020071func parseConfig() *appConfig {
72 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020073
74 desc := config.Description
75 desc += " [" + config.Version + "]"
76
Akron1fc750e2025-05-26 16:54:18 +020077 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020078 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020079 kong.UsageOnError(),
80 )
81 if ctx.Error != nil {
82 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020083 os.Exit(1)
84 }
Akron49ceeb42025-05-23 17:46:01 +020085 return cfg
86}
87
88func setupLogger(level string) {
89 // Parse log level
90 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
91 if err != nil {
92 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
93 lvl = zerolog.InfoLevel
94 }
95
96 // Configure zerolog
97 zerolog.SetGlobalLevel(lvl)
98 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
99}
100
Akron3caee162025-07-01 17:44:58 +0200101// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
102func setupFiberLogger() fiber.Handler {
103 // Check if HTTP request logging should be enabled based on current log level
104 currentLevel := zerolog.GlobalLevel()
105
106 // Only enable HTTP request logging if log level is debug or info
107 if currentLevel > zerolog.InfoLevel {
108 return func(c *fiber.Ctx) error {
109 return c.Next()
110 }
111 }
112
113 return func(c *fiber.Ctx) error {
114 // Record start time
115 start := time.Now()
116
117 // Process request
118 err := c.Next()
119
120 // Calculate latency
121 latency := time.Since(start)
122 status := c.Response().StatusCode()
123
124 // Determine log level based on status code
125 logEvent := log.Info()
126 if status >= 400 && status < 500 {
127 logEvent = log.Warn()
128 } else if status >= 500 {
129 logEvent = log.Error()
130 }
131
132 // Log the request
133 logEvent.
134 Int("status", status).
135 Dur("latency", latency).
136 Str("method", c.Method()).
137 Str("path", c.Path()).
138 Str("ip", c.IP()).
139 Str("user_agent", c.Get("User-Agent")).
140 Msg("HTTP request")
141
142 return err
143 }
144}
145
Akron49b525c2025-07-03 15:17:06 +0200146// extractRequestParams extracts and validates common request parameters
147func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
148 params := &requestParams{
149 MapID: c.Params("map"),
150 Dir: c.Query("dir", "atob"),
151 FoundryA: c.Query("foundryA", ""),
152 FoundryB: c.Query("foundryB", ""),
153 LayerA: c.Query("layerA", ""),
154 LayerB: c.Query("layerB", ""),
155 }
156
157 // Validate input parameters
158 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
159 return nil, err
160 }
161
162 // Validate direction
163 if params.Dir != "atob" && params.Dir != "btoa" {
164 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
165 }
166
167 return params, nil
168}
169
170// parseRequestBody parses JSON request body and direction
171func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
172 var jsonData any
173 if err := c.BodyParser(&jsonData); err != nil {
174 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
175 }
176
177 direction, err := mapper.ParseDirection(dir)
178 if err != nil {
179 return nil, mapper.BtoA, err
180 }
181
182 return jsonData, direction, nil
183}
184
Akron49ceeb42025-05-23 17:46:01 +0200185func main() {
186 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200187 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200188
Akrone1cff7c2025-06-04 18:43:32 +0200189 // Validate command line arguments
190 if cfg.Config == "" && len(cfg.Mappings) == 0 {
191 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
192 }
193
Akron14678dc2025-06-05 13:01:38 +0200194 // Expand glob patterns in mapping files
195 expandedMappings, err := expandGlobs(cfg.Mappings)
196 if err != nil {
197 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
198 }
199
Akrone1cff7c2025-06-04 18:43:32 +0200200 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200201 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200202 if err != nil {
203 log.Fatal().Err(err).Msg("Failed to load configuration")
204 }
205
Akrona8a66ce2025-06-05 10:50:17 +0200206 finalPort := yamlConfig.Port
207 finalLogLevel := yamlConfig.LogLevel
208
209 // Use command line values if provided (they override config file)
210 if cfg.Port != nil {
211 finalPort = *cfg.Port
212 }
213 if cfg.LogLevel != nil {
214 finalLogLevel = *cfg.LogLevel
215 }
216
217 // Set up logging with the final log level
218 setupLogger(finalLogLevel)
219
Akron49ceeb42025-05-23 17:46:01 +0200220 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200221 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200222 if err != nil {
223 log.Fatal().Err(err).Msg("Failed to create mapper")
224 }
225
226 // Create fiber app
227 app := fiber.New(fiber.Config{
228 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200229 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200230 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
231 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200232 })
233
Akron3caee162025-07-01 17:44:58 +0200234 // Add zerolog-integrated logger middleware
235 app.Use(setupFiberLogger())
236
Akron49ceeb42025-05-23 17:46:01 +0200237 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200238 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200239
240 // Start server
241 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200242 log.Info().Int("port", finalPort).Msg("Starting server")
Akronae3ffde2025-06-05 14:04:06 +0200243
244 for _, list := range yamlConfig.Lists {
245 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
246 }
247
Akrona8a66ce2025-06-05 10:50:17 +0200248 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200249 log.Fatal().Err(err).Msg("Server error")
250 }
251 }()
252
253 // Wait for interrupt signal
254 sigChan := make(chan os.Signal, 1)
255 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
256 <-sigChan
257
258 // Graceful shutdown
259 log.Info().Msg("Shutting down server")
260 if err := app.Shutdown(); err != nil {
261 log.Error().Err(err).Msg("Error during shutdown")
262 }
263}
264
Akron06d21f02025-06-04 14:36:07 +0200265func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200266 // Health check endpoint
267 app.Get("/health", func(c *fiber.Ctx) error {
268 return c.SendString("OK")
269 })
270
Akron512aab62026-02-20 08:36:12 +0100271 // Composite cascade transformation endpoints
272 app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
273 app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
274
Akron49ceeb42025-05-23 17:46:01 +0200275 // Transformation endpoint
276 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200277
Akron4de47a92025-06-27 11:58:11 +0200278 // Response transformation endpoint
279 app.Post("/:map/response", handleResponseTransform(m))
280
Akron40aaa632025-06-03 17:57:52 +0200281 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200282 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200283 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200284}
285
Akron512aab62026-02-20 08:36:12 +0100286func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
287 return func(c *fiber.Ctx) error {
288 cfgRaw := c.Query("cfg", "")
289 if len(cfgRaw) > maxParamLength {
290 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
291 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
292 })
293 }
294
295 var jsonData any
296 if err := c.BodyParser(&jsonData); err != nil {
297 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
298 "error": "invalid JSON in request body",
299 })
300 }
301
302 entries, err := ParseCfgParam(cfgRaw, lists)
303 if err != nil {
304 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
305 "error": err.Error(),
306 })
307 }
308
309 if len(entries) == 0 {
310 return c.JSON(jsonData)
311 }
312
313 orderedIDs := make([]string, 0, len(entries))
314 opts := make([]mapper.MappingOptions, 0, len(entries))
315 for _, entry := range entries {
316 dir := mapper.AtoB
317 if entry.Direction == "btoa" {
318 dir = mapper.BtoA
319 }
320
321 orderedIDs = append(orderedIDs, entry.ID)
322 opts = append(opts, mapper.MappingOptions{
323 Direction: dir,
324 FoundryA: entry.FoundryA,
325 LayerA: entry.LayerA,
326 FoundryB: entry.FoundryB,
327 LayerB: entry.LayerB,
328 })
329 }
330
331 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
332 if err != nil {
333 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
334 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
335 "error": err.Error(),
336 })
337 }
338
339 return c.JSON(result)
340 }
341}
342
343func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
344 return func(c *fiber.Ctx) error {
345 cfgRaw := c.Query("cfg", "")
346 if len(cfgRaw) > maxParamLength {
347 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
348 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
349 })
350 }
351
352 var jsonData any
353 if err := c.BodyParser(&jsonData); err != nil {
354 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
355 "error": "invalid JSON in request body",
356 })
357 }
358
359 entries, err := ParseCfgParam(cfgRaw, lists)
360 if err != nil {
361 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
362 "error": err.Error(),
363 })
364 }
365
366 if len(entries) == 0 {
367 return c.JSON(jsonData)
368 }
369
370 orderedIDs := make([]string, 0, len(entries))
371 opts := make([]mapper.MappingOptions, 0, len(entries))
372 for _, entry := range entries {
373 dir := mapper.AtoB
374 if entry.Direction == "btoa" {
375 dir = mapper.BtoA
376 }
377
378 orderedIDs = append(orderedIDs, entry.ID)
379 opts = append(opts, mapper.MappingOptions{
380 Direction: dir,
381 FoundryA: entry.FoundryA,
382 LayerA: entry.LayerA,
383 FoundryB: entry.FoundryB,
384 LayerB: entry.LayerB,
385 })
386 }
387
388 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
389 if err != nil {
390 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
391 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
392 "error": err.Error(),
393 })
394 }
395
396 return c.JSON(result)
397 }
398}
399
Akron49ceeb42025-05-23 17:46:01 +0200400func handleTransform(m *mapper.Mapper) fiber.Handler {
401 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200402 // Extract and validate parameters
403 params, err := extractRequestParams(c)
404 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200405 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
406 "error": err.Error(),
407 })
408 }
409
Akron49ceeb42025-05-23 17:46:01 +0200410 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200411 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200412 if err != nil {
413 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
414 "error": err.Error(),
415 })
416 }
417
Akron49ceeb42025-05-23 17:46:01 +0200418 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200419 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200420 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200421 FoundryA: params.FoundryA,
422 FoundryB: params.FoundryB,
423 LayerA: params.LayerA,
424 LayerB: params.LayerB,
Akron49ceeb42025-05-23 17:46:01 +0200425 }, jsonData)
426
427 if err != nil {
428 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200429 Str("mapID", params.MapID).
430 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200431 Msg("Failed to apply mappings")
432
433 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
434 "error": err.Error(),
435 })
436 }
437
438 return c.JSON(result)
439 }
440}
Akron74e1c072025-05-26 14:38:25 +0200441
Akron4de47a92025-06-27 11:58:11 +0200442func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
443 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200444 // Extract and validate parameters
445 params, err := extractRequestParams(c)
446 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200447 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
448 "error": err.Error(),
449 })
450 }
451
Akron4de47a92025-06-27 11:58:11 +0200452 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200453 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200454 if err != nil {
455 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
456 "error": err.Error(),
457 })
458 }
459
460 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200461 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron4de47a92025-06-27 11:58:11 +0200462 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200463 FoundryA: params.FoundryA,
464 FoundryB: params.FoundryB,
465 LayerA: params.LayerA,
466 LayerB: params.LayerB,
Akron4de47a92025-06-27 11:58:11 +0200467 }, jsonData)
468
469 if err != nil {
470 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200471 Str("mapID", params.MapID).
472 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200473 Msg("Failed to apply response mappings")
474
475 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
476 "error": err.Error(),
477 })
478 }
479
480 return c.JSON(result)
481 }
482}
483
Akron74e1c072025-05-26 14:38:25 +0200484// validateInput checks if the input parameters are valid
485func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200486 // Define parameter checks
487 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200488 name string
489 value string
490 }{
491 {"mapID", mapID},
492 {"dir", dir},
493 {"foundryA", foundryA},
494 {"foundryB", foundryB},
495 {"layerA", layerA},
496 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200497 }
498
499 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200500 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200501 if len(param.value) > maxParamLength {
502 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
503 }
Akron74e1c072025-05-26 14:38:25 +0200504 if strings.ContainsAny(param.value, "<>{}[]\\") {
505 return fmt.Errorf("%s contains invalid characters", param.name)
506 }
507 }
508
Akron69d43bf2025-05-26 17:09:00 +0200509 if len(body) > maxInputLength {
510 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
511 }
512
Akron74e1c072025-05-26 14:38:25 +0200513 return nil
514}
Akron40aaa632025-06-03 17:57:52 +0200515
Akron06d21f02025-06-04 14:36:07 +0200516func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200517 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200518 mapID := c.Params("map")
519
Akroncb51f812025-06-30 15:24:20 +0200520 // Get query parameters
521 dir := c.Query("dir", "atob")
522 foundryA := c.Query("foundryA", "")
523 foundryB := c.Query("foundryB", "")
524 layerA := c.Query("layerA", "")
525 layerB := c.Query("layerB", "")
526
Akron49b525c2025-07-03 15:17:06 +0200527 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200528 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
529 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
530 "error": err.Error(),
531 })
532 }
533
Akroncb51f812025-06-30 15:24:20 +0200534 if dir != "atob" && dir != "btoa" {
535 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
536 "error": "invalid direction, must be 'atob' or 'btoa'",
537 })
538 }
539
Akrondab27112025-06-05 13:52:43 +0200540 // Get list of available mappings
541 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200542 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200543 mappings = append(mappings, TemplateMapping{
544 ID: list.ID,
545 Description: list.Description,
546 })
Akron40aaa632025-06-03 17:57:52 +0200547 }
548
549 // Prepare template data
550 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200551 Title: config.Title,
552 Version: config.Version,
553 Hash: config.Buildhash,
554 Date: config.Buildtime,
555 Description: config.Description,
Akron49b525c2025-07-03 15:17:06 +0200556 Server: yamlConfig.Server,
557 SDK: yamlConfig.SDK,
Akron2ac2ec02025-06-05 15:26:42 +0200558 ServiceURL: yamlConfig.ServiceURL,
Akronc376dcc2025-06-04 17:00:18 +0200559 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200560 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200561 }
562
Akroncb51f812025-06-30 15:24:20 +0200563 // Add query parameters to template data
564 queryParams := QueryParams{
565 Dir: dir,
566 FoundryA: foundryA,
567 FoundryB: foundryB,
568 LayerA: layerA,
569 LayerB: layerB,
570 }
571
Akron40aaa632025-06-03 17:57:52 +0200572 // Generate HTML
Akroncb51f812025-06-30 15:24:20 +0200573 html := generateKalamarPluginHTML(data, queryParams)
Akron40aaa632025-06-03 17:57:52 +0200574
575 c.Set("Content-Type", "text/html")
576 return c.SendString(html)
577 }
578}
579
580// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
581// This function can be easily modified to change the appearance and content
Akroncb51f812025-06-30 15:24:20 +0200582func generateKalamarPluginHTML(data TemplateData, queryParams QueryParams) string {
Akron40aaa632025-06-03 17:57:52 +0200583 html := `<!DOCTYPE html>
584<html lang="en">
585<head>
586 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200587 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200588 <script src="` + data.SDK + `"
589 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200590</head>
591<body>
592 <div class="container">
593 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200594 <p>` + data.Description + `</p>`
595
596 if data.MapID != "" {
597 html += `<p>Map ID: ` + data.MapID + `</p>`
598 }
599
600 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200601 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
602 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
603 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200604
Akronc471c0a2025-06-04 11:56:22 +0200605 <h2>Available API Endpoints</h2>
606 <dl>
Akron40aaa632025-06-03 17:57:52 +0200607
Akronc376dcc2025-06-04 17:00:18 +0200608 <dt><tt><strong>GET</strong> /:map</tt></dt>
609 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200610
611 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200612 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
Akron4de47a92025-06-27 11:58:11 +0200613
614 <dt><tt><strong>POST</strong> /:map/response</tt></dt>
615 <dd><small>Transform JSON response objects using term mapping rules</small></dd>
Akronc376dcc2025-06-04 17:00:18 +0200616
Akronc471c0a2025-06-04 11:56:22 +0200617 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200618
619 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200620 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200621
Akrondab27112025-06-05 13:52:43 +0200622 for _, m := range data.Mappings {
623 html += `<dt><tt>` + m.ID + `</tt></dt>`
624 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200625 }
626
627 html += `
Akroncb51f812025-06-30 15:24:20 +0200628 </dl></div>`
Akron06d21f02025-06-04 14:36:07 +0200629
Akronc376dcc2025-06-04 17:00:18 +0200630 if data.MapID != "" {
Akron80067202025-06-06 14:16:25 +0200631
Akrond0c88602025-06-27 16:57:21 +0200632 queryServiceURL, err := url.Parse(data.ServiceURL)
Akron80067202025-06-06 14:16:25 +0200633 if err != nil {
634 log.Warn().Err(err).Msg("Failed to join URL path")
635 }
636
637 // Use path.Join to normalize the path part
Akrond0c88602025-06-27 16:57:21 +0200638 queryServiceURL.Path = path.Join(queryServiceURL.Path, data.MapID+"/query")
Akroncb51f812025-06-30 15:24:20 +0200639
640 // Build query parameters for query URL
641 queryParamString := buildQueryParams(queryParams.Dir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
642 queryServiceURL.RawQuery = queryParamString
Akrond0c88602025-06-27 16:57:21 +0200643
644 responseServiceURL, err := url.Parse(data.ServiceURL)
645 if err != nil {
646 log.Warn().Err(err).Msg("Failed to join URL path")
647 }
648
649 // Use path.Join to normalize the path part
650 responseServiceURL.Path = path.Join(responseServiceURL.Path, data.MapID+"/response")
Akron80067202025-06-06 14:16:25 +0200651
Akroncb51f812025-06-30 15:24:20 +0200652 reversedDir := "btoa"
653 if queryParams.Dir == "btoa" {
654 reversedDir = "atob"
655 }
656
657 // Build query parameters for response URL (with reversed direction)
658 responseParamString := buildQueryParams(reversedDir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
659 responseServiceURL.RawQuery = responseParamString
660
661 html += `<script>
Akron06d21f02025-06-04 14:36:07 +0200662 <!-- activates/deactivates Mapper. -->
663
Akron3caee162025-07-01 17:44:58 +0200664 let qdata = {
665 'action' : 'pipe',
666 'service' : '` + queryServiceURL.String() + `'
667 };
Akron06d21f02025-06-04 14:36:07 +0200668
Akron3caee162025-07-01 17:44:58 +0200669 let rdata = {
670 'action' : 'pipe',
671 'service' : '` + responseServiceURL.String() + `'
672 };
Akrond0c88602025-06-27 16:57:21 +0200673
674
Akron3caee162025-07-01 17:44:58 +0200675 function pluginit (p) {
676 p.onMessage = function(msg) {
Akron2ef703c2025-07-03 15:57:42 +0200677 if (msg.key == 'koralmapper') {
Akron3caee162025-07-01 17:44:58 +0200678 if (msg.value) {
679 qdata['job'] = 'add';
680 }
681 else {
682 qdata['job'] = 'del';
683 };
684 KorAPlugin.sendMsg(qdata);
Akrond0c88602025-06-27 16:57:21 +0200685 if (msg.value) {
Akron3caee162025-07-01 17:44:58 +0200686 rdata['job'] = 'add-after';
687 }
688 else {
689 rdata['job'] = 'del-after';
690 };
691 KorAPlugin.sendMsg(rdata);
692 };
693 };
694 };
695 </script>`
Akronc376dcc2025-06-04 17:00:18 +0200696 }
697
698 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200699</html>`
700
701 return html
702}
Akron14678dc2025-06-05 13:01:38 +0200703
Akroncb51f812025-06-30 15:24:20 +0200704// buildQueryParams builds a query string from the provided parameters
705func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
706 params := url.Values{}
707 if dir != "" {
708 params.Add("dir", dir)
709 }
710 if foundryA != "" {
711 params.Add("foundryA", foundryA)
712 }
713 if foundryB != "" {
714 params.Add("foundryB", foundryB)
715 }
716 if layerA != "" {
717 params.Add("layerA", layerA)
718 }
719 if layerB != "" {
720 params.Add("layerB", layerB)
721 }
722 return params.Encode()
723}
724
Akron14678dc2025-06-05 13:01:38 +0200725// expandGlobs expands glob patterns in the slice of file paths
726// Returns the expanded list of files or an error if glob expansion fails
727func expandGlobs(patterns []string) ([]string, error) {
728 var expanded []string
729
730 for _, pattern := range patterns {
731 // Use filepath.Glob which works cross-platform
732 matches, err := filepath.Glob(pattern)
733 if err != nil {
734 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
735 }
736
737 // If no matches found, treat as literal filename (consistent with shell behavior)
738 if len(matches) == 0 {
739 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
740 expanded = append(expanded, pattern)
741 } else {
742 expanded = append(expanded, matches...)
743 }
744 }
745
746 return expanded, nil
747}