blob: 644fa0699422da9ab01a573730dbb912211ae082 [file] [log] [blame]
Akron49ceeb42025-05-23 17:46:01 +02001package main
2
3import (
Akron49ceeb42025-05-23 17:46:01 +02004 "fmt"
5 "os"
6 "os/signal"
Akron14678dc2025-06-05 13:01:38 +02007 "path/filepath"
Akron49ceeb42025-05-23 17:46:01 +02008 "strings"
9 "syscall"
10
Akrona00d4752025-05-26 17:34:36 +020011 "github.com/KorAP/KoralPipe-TermMapper/config"
Akronfa55bb22025-05-26 15:10:42 +020012 "github.com/KorAP/KoralPipe-TermMapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020013 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020014 "github.com/gofiber/fiber/v2"
15 "github.com/rs/zerolog"
16 "github.com/rs/zerolog/log"
17)
18
Akron74e1c072025-05-26 14:38:25 +020019const (
20 maxInputLength = 1024 * 1024 // 1MB
21 maxParamLength = 1024 // 1KB
22)
23
Akrona00d4752025-05-26 17:34:36 +020024type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020025 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020026 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020027 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020028 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020029}
30
Akrondab27112025-06-05 13:52:43 +020031type TemplateMapping struct {
32 ID string
33 Description string
34}
35
Akron40aaa632025-06-03 17:57:52 +020036// TemplateData holds data for the Kalamar plugin template
37type TemplateData struct {
38 Title string
39 Version string
Akronfc77b5e2025-06-04 11:44:43 +020040 Hash string
41 Date string
Akron40aaa632025-06-03 17:57:52 +020042 Description string
Akron06d21f02025-06-04 14:36:07 +020043 Server string
44 SDK string
Akron2ac2ec02025-06-05 15:26:42 +020045 ServiceURL string
Akronc376dcc2025-06-04 17:00:18 +020046 MapID string
Akrondab27112025-06-05 13:52:43 +020047 Mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +020048}
49
Akrona00d4752025-05-26 17:34:36 +020050func parseConfig() *appConfig {
51 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020052
53 desc := config.Description
54 desc += " [" + config.Version + "]"
55
Akron1fc750e2025-05-26 16:54:18 +020056 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020057 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020058 kong.UsageOnError(),
59 )
60 if ctx.Error != nil {
61 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020062 os.Exit(1)
63 }
Akron49ceeb42025-05-23 17:46:01 +020064 return cfg
65}
66
67func setupLogger(level string) {
68 // Parse log level
69 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
70 if err != nil {
71 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
72 lvl = zerolog.InfoLevel
73 }
74
75 // Configure zerolog
76 zerolog.SetGlobalLevel(lvl)
77 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
78}
79
80func main() {
81 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020082 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020083
Akrone1cff7c2025-06-04 18:43:32 +020084 // Validate command line arguments
85 if cfg.Config == "" && len(cfg.Mappings) == 0 {
86 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
87 }
88
Akron14678dc2025-06-05 13:01:38 +020089 // Expand glob patterns in mapping files
90 expandedMappings, err := expandGlobs(cfg.Mappings)
91 if err != nil {
92 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
93 }
94
Akrone1cff7c2025-06-04 18:43:32 +020095 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +020096 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +020097 if err != nil {
98 log.Fatal().Err(err).Msg("Failed to load configuration")
99 }
100
Akrona8a66ce2025-06-05 10:50:17 +0200101 finalPort := yamlConfig.Port
102 finalLogLevel := yamlConfig.LogLevel
103
104 // Use command line values if provided (they override config file)
105 if cfg.Port != nil {
106 finalPort = *cfg.Port
107 }
108 if cfg.LogLevel != nil {
109 finalLogLevel = *cfg.LogLevel
110 }
111
112 // Set up logging with the final log level
113 setupLogger(finalLogLevel)
114
Akron49ceeb42025-05-23 17:46:01 +0200115 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200116 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200117 if err != nil {
118 log.Fatal().Err(err).Msg("Failed to create mapper")
119 }
120
121 // Create fiber app
122 app := fiber.New(fiber.Config{
123 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200124 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +0200125 })
126
127 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200128 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200129
130 // Start server
131 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200132 log.Info().Int("port", finalPort).Msg("Starting server")
Akronae3ffde2025-06-05 14:04:06 +0200133
134 for _, list := range yamlConfig.Lists {
135 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
136 }
137
Akrona8a66ce2025-06-05 10:50:17 +0200138 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200139 log.Fatal().Err(err).Msg("Server error")
140 }
141 }()
142
143 // Wait for interrupt signal
144 sigChan := make(chan os.Signal, 1)
145 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
146 <-sigChan
147
148 // Graceful shutdown
149 log.Info().Msg("Shutting down server")
150 if err := app.Shutdown(); err != nil {
151 log.Error().Err(err).Msg("Error during shutdown")
152 }
153}
154
Akron06d21f02025-06-04 14:36:07 +0200155func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200156 // Health check endpoint
157 app.Get("/health", func(c *fiber.Ctx) error {
158 return c.SendString("OK")
159 })
160
161 // Transformation endpoint
162 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200163
164 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200165 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200166 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200167}
168
169func handleTransform(m *mapper.Mapper) fiber.Handler {
170 return func(c *fiber.Ctx) error {
171 // Get parameters
172 mapID := c.Params("map")
173 dir := c.Query("dir", "atob")
174 foundryA := c.Query("foundryA", "")
175 foundryB := c.Query("foundryB", "")
176 layerA := c.Query("layerA", "")
177 layerB := c.Query("layerB", "")
178
Akron74e1c072025-05-26 14:38:25 +0200179 // Validate input parameters
180 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
181 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
182 "error": err.Error(),
183 })
184 }
185
Akron49ceeb42025-05-23 17:46:01 +0200186 // Validate direction
187 if dir != "atob" && dir != "btoa" {
188 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
189 "error": "invalid direction, must be 'atob' or 'btoa'",
190 })
191 }
192
193 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200194 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200195 if err := c.BodyParser(&jsonData); err != nil {
196 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
197 "error": "invalid JSON in request body",
198 })
199 }
200
Akrona1a183f2025-05-26 17:47:33 +0200201 // Parse direction
202 direction, err := mapper.ParseDirection(dir)
203 if err != nil {
204 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
205 "error": err.Error(),
206 })
207 }
208
Akron49ceeb42025-05-23 17:46:01 +0200209 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200210 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200211 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200212 FoundryA: foundryA,
213 FoundryB: foundryB,
214 LayerA: layerA,
215 LayerB: layerB,
216 }, jsonData)
217
218 if err != nil {
219 log.Error().Err(err).
220 Str("mapID", mapID).
221 Str("direction", dir).
222 Msg("Failed to apply mappings")
223
224 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
225 "error": err.Error(),
226 })
227 }
228
229 return c.JSON(result)
230 }
231}
Akron74e1c072025-05-26 14:38:25 +0200232
233// validateInput checks if the input parameters are valid
234func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200235 // Define parameter checks
236 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200237 name string
238 value string
239 }{
240 {"mapID", mapID},
241 {"dir", dir},
242 {"foundryA", foundryA},
243 {"foundryB", foundryB},
244 {"layerA", layerA},
245 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200246 }
247
248 for _, param := range params {
249 // Check input lengths
250 if len(param.value) > maxParamLength {
251 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
252 }
253 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200254 if strings.ContainsAny(param.value, "<>{}[]\\") {
255 return fmt.Errorf("%s contains invalid characters", param.name)
256 }
257 }
258
Akron69d43bf2025-05-26 17:09:00 +0200259 if len(body) > maxInputLength {
260 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
261 }
262
Akron74e1c072025-05-26 14:38:25 +0200263 return nil
264}
Akron40aaa632025-06-03 17:57:52 +0200265
Akron06d21f02025-06-04 14:36:07 +0200266func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200267 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200268 mapID := c.Params("map")
269
Akrondab27112025-06-05 13:52:43 +0200270 // Get list of available mappings
271 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200272 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200273 mappings = append(mappings, TemplateMapping{
274 ID: list.ID,
275 Description: list.Description,
276 })
Akron40aaa632025-06-03 17:57:52 +0200277 }
278
Akron06d21f02025-06-04 14:36:07 +0200279 // Use values from config (defaults are already applied during parsing)
280 server := yamlConfig.Server
281 sdk := yamlConfig.SDK
282
Akron40aaa632025-06-03 17:57:52 +0200283 // Prepare template data
284 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200285 Title: config.Title,
286 Version: config.Version,
287 Hash: config.Buildhash,
288 Date: config.Buildtime,
289 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200290 Server: server,
291 SDK: sdk,
Akron2ac2ec02025-06-05 15:26:42 +0200292 ServiceURL: yamlConfig.ServiceURL,
Akronc376dcc2025-06-04 17:00:18 +0200293 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200294 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200295 }
296
297 // Generate HTML
298 html := generateKalamarPluginHTML(data)
299
300 c.Set("Content-Type", "text/html")
301 return c.SendString(html)
302 }
303}
304
305// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
306// This function can be easily modified to change the appearance and content
307func generateKalamarPluginHTML(data TemplateData) string {
308 html := `<!DOCTYPE html>
309<html lang="en">
310<head>
311 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200312 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200313 <script src="` + data.SDK + `"
314 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200315</head>
316<body>
317 <div class="container">
318 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200319 <p>` + data.Description + `</p>`
320
321 if data.MapID != "" {
322 html += `<p>Map ID: ` + data.MapID + `</p>`
323 }
324
325 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200326 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
327 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
328 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200329
Akronc471c0a2025-06-04 11:56:22 +0200330 <h2>Available API Endpoints</h2>
331 <dl>
Akron40aaa632025-06-03 17:57:52 +0200332
Akronc376dcc2025-06-04 17:00:18 +0200333 <dt><tt><strong>GET</strong> /:map</tt></dt>
334 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200335
336 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200337 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
338
Akronc471c0a2025-06-04 11:56:22 +0200339 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200340
341 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200342 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200343
Akrondab27112025-06-05 13:52:43 +0200344 for _, m := range data.Mappings {
345 html += `<dt><tt>` + m.ID + `</tt></dt>`
346 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200347 }
348
349 html += `
Akrondab27112025-06-05 13:52:43 +0200350 </dl>`
Akron06d21f02025-06-04 14:36:07 +0200351
Akronc376dcc2025-06-04 17:00:18 +0200352 if data.MapID != "" {
353 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200354 <!-- activates/deactivates Mapper. -->
355
356 let data = {
357 'action' : 'pipe',
Akron2ac2ec02025-06-05 15:26:42 +0200358 'service' : '` + data.ServiceURL + `/` + data.MapID + `/query'
Akron06d21f02025-06-04 14:36:07 +0200359 };
360
361 function pluginit (p) {
362 p.onMessage = function(msg) {
363 if (msg.key == 'termmapper') {
364 if (msg.value) {
365 data['job'] = 'add';
366 }
367 else {
368 data['job'] = 'del';
369 };
370 KorAPlugin.sendMsg(data);
371 };
372 };
373 };
Akronc376dcc2025-06-04 17:00:18 +0200374 </script>`
375 }
376
377 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200378</html>`
379
380 return html
381}
Akron14678dc2025-06-05 13:01:38 +0200382
383// expandGlobs expands glob patterns in the slice of file paths
384// Returns the expanded list of files or an error if glob expansion fails
385func expandGlobs(patterns []string) ([]string, error) {
386 var expanded []string
387
388 for _, pattern := range patterns {
389 // Use filepath.Glob which works cross-platform
390 matches, err := filepath.Glob(pattern)
391 if err != nil {
392 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
393 }
394
395 // If no matches found, treat as literal filename (consistent with shell behavior)
396 if len(matches) == 0 {
397 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
398 expanded = append(expanded, pattern)
399 } else {
400 expanded = append(expanded, matches...)
401 }
402 }
403
404 return expanded, nil
405}