blob: 006a00950d853c5a4571a5983f017676cfef3606 [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"
12
Akrona00d4752025-05-26 17:34:36 +020013 "github.com/KorAP/KoralPipe-TermMapper/config"
Akronfa55bb22025-05-26 15:10:42 +020014 "github.com/KorAP/KoralPipe-TermMapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020015 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020016 "github.com/gofiber/fiber/v2"
17 "github.com/rs/zerolog"
18 "github.com/rs/zerolog/log"
19)
20
Akron74e1c072025-05-26 14:38:25 +020021const (
22 maxInputLength = 1024 * 1024 // 1MB
23 maxParamLength = 1024 // 1KB
24)
25
Akrona00d4752025-05-26 17:34:36 +020026type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020027 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020028 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020029 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020030 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020031}
32
Akrondab27112025-06-05 13:52:43 +020033type TemplateMapping struct {
34 ID string
35 Description string
36}
37
Akron40aaa632025-06-03 17:57:52 +020038// TemplateData holds data for the Kalamar plugin template
39type TemplateData struct {
40 Title string
41 Version string
Akronfc77b5e2025-06-04 11:44:43 +020042 Hash string
43 Date string
Akron40aaa632025-06-03 17:57:52 +020044 Description string
Akron06d21f02025-06-04 14:36:07 +020045 Server string
46 SDK string
Akron2ac2ec02025-06-05 15:26:42 +020047 ServiceURL string
Akronc376dcc2025-06-04 17:00:18 +020048 MapID string
Akrondab27112025-06-05 13:52:43 +020049 Mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +020050}
51
Akrona00d4752025-05-26 17:34:36 +020052func parseConfig() *appConfig {
53 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020054
55 desc := config.Description
56 desc += " [" + config.Version + "]"
57
Akron1fc750e2025-05-26 16:54:18 +020058 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020059 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020060 kong.UsageOnError(),
61 )
62 if ctx.Error != nil {
63 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020064 os.Exit(1)
65 }
Akron49ceeb42025-05-23 17:46:01 +020066 return cfg
67}
68
69func setupLogger(level string) {
70 // Parse log level
71 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
72 if err != nil {
73 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
74 lvl = zerolog.InfoLevel
75 }
76
77 // Configure zerolog
78 zerolog.SetGlobalLevel(lvl)
79 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
80}
81
82func main() {
83 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020084 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020085
Akrone1cff7c2025-06-04 18:43:32 +020086 // Validate command line arguments
87 if cfg.Config == "" && len(cfg.Mappings) == 0 {
88 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
89 }
90
Akron14678dc2025-06-05 13:01:38 +020091 // Expand glob patterns in mapping files
92 expandedMappings, err := expandGlobs(cfg.Mappings)
93 if err != nil {
94 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
95 }
96
Akrone1cff7c2025-06-04 18:43:32 +020097 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +020098 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +020099 if err != nil {
100 log.Fatal().Err(err).Msg("Failed to load configuration")
101 }
102
Akrona8a66ce2025-06-05 10:50:17 +0200103 finalPort := yamlConfig.Port
104 finalLogLevel := yamlConfig.LogLevel
105
106 // Use command line values if provided (they override config file)
107 if cfg.Port != nil {
108 finalPort = *cfg.Port
109 }
110 if cfg.LogLevel != nil {
111 finalLogLevel = *cfg.LogLevel
112 }
113
114 // Set up logging with the final log level
115 setupLogger(finalLogLevel)
116
Akron49ceeb42025-05-23 17:46:01 +0200117 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200118 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200119 if err != nil {
120 log.Fatal().Err(err).Msg("Failed to create mapper")
121 }
122
123 // Create fiber app
124 app := fiber.New(fiber.Config{
125 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200126 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +0200127 })
128
129 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200130 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200131
132 // Start server
133 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200134 log.Info().Int("port", finalPort).Msg("Starting server")
Akronae3ffde2025-06-05 14:04:06 +0200135
136 for _, list := range yamlConfig.Lists {
137 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
138 }
139
Akrona8a66ce2025-06-05 10:50:17 +0200140 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200141 log.Fatal().Err(err).Msg("Server error")
142 }
143 }()
144
145 // Wait for interrupt signal
146 sigChan := make(chan os.Signal, 1)
147 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
148 <-sigChan
149
150 // Graceful shutdown
151 log.Info().Msg("Shutting down server")
152 if err := app.Shutdown(); err != nil {
153 log.Error().Err(err).Msg("Error during shutdown")
154 }
155}
156
Akron06d21f02025-06-04 14:36:07 +0200157func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200158 // Health check endpoint
159 app.Get("/health", func(c *fiber.Ctx) error {
160 return c.SendString("OK")
161 })
162
163 // Transformation endpoint
164 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200165
Akron4de47a92025-06-27 11:58:11 +0200166 // Response transformation endpoint
167 app.Post("/:map/response", handleResponseTransform(m))
168
Akron40aaa632025-06-03 17:57:52 +0200169 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200170 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200171 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200172}
173
174func handleTransform(m *mapper.Mapper) fiber.Handler {
175 return func(c *fiber.Ctx) error {
176 // Get parameters
177 mapID := c.Params("map")
178 dir := c.Query("dir", "atob")
179 foundryA := c.Query("foundryA", "")
180 foundryB := c.Query("foundryB", "")
181 layerA := c.Query("layerA", "")
182 layerB := c.Query("layerB", "")
183
Akron74e1c072025-05-26 14:38:25 +0200184 // Validate input parameters
185 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
186 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
187 "error": err.Error(),
188 })
189 }
190
Akron49ceeb42025-05-23 17:46:01 +0200191 // Validate direction
192 if dir != "atob" && dir != "btoa" {
193 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
194 "error": "invalid direction, must be 'atob' or 'btoa'",
195 })
196 }
197
198 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200199 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200200 if err := c.BodyParser(&jsonData); err != nil {
201 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
202 "error": "invalid JSON in request body",
203 })
204 }
205
Akrona1a183f2025-05-26 17:47:33 +0200206 // Parse direction
207 direction, err := mapper.ParseDirection(dir)
208 if err != nil {
209 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
210 "error": err.Error(),
211 })
212 }
213
Akron49ceeb42025-05-23 17:46:01 +0200214 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200215 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200216 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200217 FoundryA: foundryA,
218 FoundryB: foundryB,
219 LayerA: layerA,
220 LayerB: layerB,
221 }, jsonData)
222
223 if err != nil {
224 log.Error().Err(err).
225 Str("mapID", mapID).
226 Str("direction", dir).
227 Msg("Failed to apply mappings")
228
229 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
230 "error": err.Error(),
231 })
232 }
233
234 return c.JSON(result)
235 }
236}
Akron74e1c072025-05-26 14:38:25 +0200237
Akron4de47a92025-06-27 11:58:11 +0200238func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
239 return func(c *fiber.Ctx) error {
240 // Get parameters
241 mapID := c.Params("map")
242 dir := c.Query("dir", "atob")
243 foundryA := c.Query("foundryA", "")
244 foundryB := c.Query("foundryB", "")
245 layerA := c.Query("layerA", "")
246 layerB := c.Query("layerB", "")
247
248 // Validate input parameters
249 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
250 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
251 "error": err.Error(),
252 })
253 }
254
255 // Validate direction
256 if dir != "atob" && dir != "btoa" {
257 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
258 "error": "invalid direction, must be 'atob' or 'btoa'",
259 })
260 }
261
262 // Parse request body
263 var jsonData any
264 if err := c.BodyParser(&jsonData); err != nil {
265 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
266 "error": "invalid JSON in request body",
267 })
268 }
269
270 // Parse direction
271 direction, err := mapper.ParseDirection(dir)
272 if err != nil {
273 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
274 "error": err.Error(),
275 })
276 }
277
278 // Apply response mappings
279 result, err := m.ApplyResponseMappings(mapID, mapper.MappingOptions{
280 Direction: direction,
281 FoundryA: foundryA,
282 FoundryB: foundryB,
283 LayerA: layerA,
284 LayerB: layerB,
285 }, jsonData)
286
287 if err != nil {
288 log.Error().Err(err).
289 Str("mapID", mapID).
290 Str("direction", dir).
291 Msg("Failed to apply response mappings")
292
293 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
294 "error": err.Error(),
295 })
296 }
297
298 return c.JSON(result)
299 }
300}
301
Akron74e1c072025-05-26 14:38:25 +0200302// validateInput checks if the input parameters are valid
303func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200304 // Define parameter checks
305 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200306 name string
307 value string
308 }{
309 {"mapID", mapID},
310 {"dir", dir},
311 {"foundryA", foundryA},
312 {"foundryB", foundryB},
313 {"layerA", layerA},
314 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200315 }
316
317 for _, param := range params {
318 // Check input lengths
319 if len(param.value) > maxParamLength {
320 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
321 }
322 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200323 if strings.ContainsAny(param.value, "<>{}[]\\") {
324 return fmt.Errorf("%s contains invalid characters", param.name)
325 }
326 }
327
Akron69d43bf2025-05-26 17:09:00 +0200328 if len(body) > maxInputLength {
329 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
330 }
331
Akron74e1c072025-05-26 14:38:25 +0200332 return nil
333}
Akron40aaa632025-06-03 17:57:52 +0200334
Akron06d21f02025-06-04 14:36:07 +0200335func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200336 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200337 mapID := c.Params("map")
338
Akrondab27112025-06-05 13:52:43 +0200339 // Get list of available mappings
340 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200341 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200342 mappings = append(mappings, TemplateMapping{
343 ID: list.ID,
344 Description: list.Description,
345 })
Akron40aaa632025-06-03 17:57:52 +0200346 }
347
Akron06d21f02025-06-04 14:36:07 +0200348 // Use values from config (defaults are already applied during parsing)
349 server := yamlConfig.Server
350 sdk := yamlConfig.SDK
351
Akron40aaa632025-06-03 17:57:52 +0200352 // Prepare template data
353 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200354 Title: config.Title,
355 Version: config.Version,
356 Hash: config.Buildhash,
357 Date: config.Buildtime,
358 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200359 Server: server,
360 SDK: sdk,
Akron2ac2ec02025-06-05 15:26:42 +0200361 ServiceURL: yamlConfig.ServiceURL,
Akronc376dcc2025-06-04 17:00:18 +0200362 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200363 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200364 }
365
366 // Generate HTML
367 html := generateKalamarPluginHTML(data)
368
369 c.Set("Content-Type", "text/html")
370 return c.SendString(html)
371 }
372}
373
374// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
375// This function can be easily modified to change the appearance and content
376func generateKalamarPluginHTML(data TemplateData) string {
377 html := `<!DOCTYPE html>
378<html lang="en">
379<head>
380 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200381 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200382 <script src="` + data.SDK + `"
383 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200384</head>
385<body>
386 <div class="container">
387 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200388 <p>` + data.Description + `</p>`
389
390 if data.MapID != "" {
391 html += `<p>Map ID: ` + data.MapID + `</p>`
392 }
393
394 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200395 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
396 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
397 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200398
Akronc471c0a2025-06-04 11:56:22 +0200399 <h2>Available API Endpoints</h2>
400 <dl>
Akron40aaa632025-06-03 17:57:52 +0200401
Akronc376dcc2025-06-04 17:00:18 +0200402 <dt><tt><strong>GET</strong> /:map</tt></dt>
403 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200404
405 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200406 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
Akron4de47a92025-06-27 11:58:11 +0200407
408 <dt><tt><strong>POST</strong> /:map/response</tt></dt>
409 <dd><small>Transform JSON response objects using term mapping rules</small></dd>
Akronc376dcc2025-06-04 17:00:18 +0200410
Akronc471c0a2025-06-04 11:56:22 +0200411 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200412
413 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200414 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200415
Akrondab27112025-06-05 13:52:43 +0200416 for _, m := range data.Mappings {
417 html += `<dt><tt>` + m.ID + `</tt></dt>`
418 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200419 }
420
421 html += `
Akrondab27112025-06-05 13:52:43 +0200422 </dl>`
Akron06d21f02025-06-04 14:36:07 +0200423
Akronc376dcc2025-06-04 17:00:18 +0200424 if data.MapID != "" {
Akron80067202025-06-06 14:16:25 +0200425
426 serviceURL, err := url.Parse(data.ServiceURL)
427 if err != nil {
428 log.Warn().Err(err).Msg("Failed to join URL path")
429 }
430
431 // Use path.Join to normalize the path part
432 serviceURL.Path = path.Join(serviceURL.Path, data.MapID+"/query")
433
Akronc376dcc2025-06-04 17:00:18 +0200434 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200435 <!-- activates/deactivates Mapper. -->
436
437 let data = {
438 'action' : 'pipe',
Akron80067202025-06-06 14:16:25 +0200439 'service' : '` + serviceURL.String() + `'
Akron06d21f02025-06-04 14:36:07 +0200440 };
441
442 function pluginit (p) {
443 p.onMessage = function(msg) {
444 if (msg.key == 'termmapper') {
445 if (msg.value) {
446 data['job'] = 'add';
447 }
448 else {
449 data['job'] = 'del';
450 };
451 KorAPlugin.sendMsg(data);
452 };
453 };
454 };
Akronc376dcc2025-06-04 17:00:18 +0200455 </script>`
456 }
457
458 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200459</html>`
460
461 return html
462}
Akron14678dc2025-06-05 13:01:38 +0200463
464// expandGlobs expands glob patterns in the slice of file paths
465// Returns the expanded list of files or an error if glob expansion fails
466func expandGlobs(patterns []string) ([]string, error) {
467 var expanded []string
468
469 for _, pattern := range patterns {
470 // Use filepath.Glob which works cross-platform
471 matches, err := filepath.Glob(pattern)
472 if err != nil {
473 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
474 }
475
476 // If no matches found, treat as literal filename (consistent with shell behavior)
477 if len(matches) == 0 {
478 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
479 expanded = append(expanded, pattern)
480 } else {
481 expanded = append(expanded, matches...)
482 }
483 }
484
485 return expanded, nil
486}