blob: 770e52217e6641dfcb748cef57175a5f0e0934ed [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
Akronc376dcc2025-06-04 17:00:18 +020045 MapID string
Akrondab27112025-06-05 13:52:43 +020046 Mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +020047}
48
Akrona00d4752025-05-26 17:34:36 +020049func parseConfig() *appConfig {
50 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020051
52 desc := config.Description
53 desc += " [" + config.Version + "]"
54
Akron1fc750e2025-05-26 16:54:18 +020055 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020056 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020057 kong.UsageOnError(),
58 )
59 if ctx.Error != nil {
60 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020061 os.Exit(1)
62 }
Akron49ceeb42025-05-23 17:46:01 +020063 return cfg
64}
65
66func setupLogger(level string) {
67 // Parse log level
68 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
69 if err != nil {
70 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
71 lvl = zerolog.InfoLevel
72 }
73
74 // Configure zerolog
75 zerolog.SetGlobalLevel(lvl)
76 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
77}
78
79func main() {
80 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020081 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020082
Akrone1cff7c2025-06-04 18:43:32 +020083 // Validate command line arguments
84 if cfg.Config == "" && len(cfg.Mappings) == 0 {
85 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
86 }
87
Akron14678dc2025-06-05 13:01:38 +020088 // Expand glob patterns in mapping files
89 expandedMappings, err := expandGlobs(cfg.Mappings)
90 if err != nil {
91 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
92 }
93
Akrone1cff7c2025-06-04 18:43:32 +020094 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +020095 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +020096 if err != nil {
97 log.Fatal().Err(err).Msg("Failed to load configuration")
98 }
99
Akrona8a66ce2025-06-05 10:50:17 +0200100 finalPort := yamlConfig.Port
101 finalLogLevel := yamlConfig.LogLevel
102
103 // Use command line values if provided (they override config file)
104 if cfg.Port != nil {
105 finalPort = *cfg.Port
106 }
107 if cfg.LogLevel != nil {
108 finalLogLevel = *cfg.LogLevel
109 }
110
111 // Set up logging with the final log level
112 setupLogger(finalLogLevel)
113
Akron49ceeb42025-05-23 17:46:01 +0200114 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200115 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200116 if err != nil {
117 log.Fatal().Err(err).Msg("Failed to create mapper")
118 }
119
120 // Create fiber app
121 app := fiber.New(fiber.Config{
122 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200123 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +0200124 })
125
126 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200127 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200128
129 // Start server
130 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200131 log.Info().Int("port", finalPort).Msg("Starting server")
Akronae3ffde2025-06-05 14:04:06 +0200132
133 for _, list := range yamlConfig.Lists {
134 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
135 }
136
Akrona8a66ce2025-06-05 10:50:17 +0200137 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200138 log.Fatal().Err(err).Msg("Server error")
139 }
140 }()
141
142 // Wait for interrupt signal
143 sigChan := make(chan os.Signal, 1)
144 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
145 <-sigChan
146
147 // Graceful shutdown
148 log.Info().Msg("Shutting down server")
149 if err := app.Shutdown(); err != nil {
150 log.Error().Err(err).Msg("Error during shutdown")
151 }
152}
153
Akron06d21f02025-06-04 14:36:07 +0200154func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200155 // Health check endpoint
156 app.Get("/health", func(c *fiber.Ctx) error {
157 return c.SendString("OK")
158 })
159
160 // Transformation endpoint
161 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200162
163 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200164 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200165 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200166}
167
168func handleTransform(m *mapper.Mapper) fiber.Handler {
169 return func(c *fiber.Ctx) error {
170 // Get parameters
171 mapID := c.Params("map")
172 dir := c.Query("dir", "atob")
173 foundryA := c.Query("foundryA", "")
174 foundryB := c.Query("foundryB", "")
175 layerA := c.Query("layerA", "")
176 layerB := c.Query("layerB", "")
177
Akron74e1c072025-05-26 14:38:25 +0200178 // Validate input parameters
179 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
180 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
181 "error": err.Error(),
182 })
183 }
184
Akron49ceeb42025-05-23 17:46:01 +0200185 // Validate direction
186 if dir != "atob" && dir != "btoa" {
187 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
188 "error": "invalid direction, must be 'atob' or 'btoa'",
189 })
190 }
191
192 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200193 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200194 if err := c.BodyParser(&jsonData); err != nil {
195 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
196 "error": "invalid JSON in request body",
197 })
198 }
199
Akrona1a183f2025-05-26 17:47:33 +0200200 // Parse direction
201 direction, err := mapper.ParseDirection(dir)
202 if err != nil {
203 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
204 "error": err.Error(),
205 })
206 }
207
Akron49ceeb42025-05-23 17:46:01 +0200208 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200209 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200210 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200211 FoundryA: foundryA,
212 FoundryB: foundryB,
213 LayerA: layerA,
214 LayerB: layerB,
215 }, jsonData)
216
217 if err != nil {
218 log.Error().Err(err).
219 Str("mapID", mapID).
220 Str("direction", dir).
221 Msg("Failed to apply mappings")
222
223 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
224 "error": err.Error(),
225 })
226 }
227
228 return c.JSON(result)
229 }
230}
Akron74e1c072025-05-26 14:38:25 +0200231
232// validateInput checks if the input parameters are valid
233func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200234 // Define parameter checks
235 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200236 name string
237 value string
238 }{
239 {"mapID", mapID},
240 {"dir", dir},
241 {"foundryA", foundryA},
242 {"foundryB", foundryB},
243 {"layerA", layerA},
244 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200245 }
246
247 for _, param := range params {
248 // Check input lengths
249 if len(param.value) > maxParamLength {
250 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
251 }
252 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200253 if strings.ContainsAny(param.value, "<>{}[]\\") {
254 return fmt.Errorf("%s contains invalid characters", param.name)
255 }
256 }
257
Akron69d43bf2025-05-26 17:09:00 +0200258 if len(body) > maxInputLength {
259 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
260 }
261
Akron74e1c072025-05-26 14:38:25 +0200262 return nil
263}
Akron40aaa632025-06-03 17:57:52 +0200264
Akron06d21f02025-06-04 14:36:07 +0200265func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200266 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200267 mapID := c.Params("map")
268
Akrondab27112025-06-05 13:52:43 +0200269 // Get list of available mappings
270 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200271 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200272 mappings = append(mappings, TemplateMapping{
273 ID: list.ID,
274 Description: list.Description,
275 })
Akron40aaa632025-06-03 17:57:52 +0200276 }
277
Akron06d21f02025-06-04 14:36:07 +0200278 // Use values from config (defaults are already applied during parsing)
279 server := yamlConfig.Server
280 sdk := yamlConfig.SDK
281
Akron40aaa632025-06-03 17:57:52 +0200282 // Prepare template data
283 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200284 Title: config.Title,
285 Version: config.Version,
286 Hash: config.Buildhash,
287 Date: config.Buildtime,
288 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200289 Server: server,
290 SDK: sdk,
Akronc376dcc2025-06-04 17:00:18 +0200291 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200292 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200293 }
294
295 // Generate HTML
296 html := generateKalamarPluginHTML(data)
297
298 c.Set("Content-Type", "text/html")
299 return c.SendString(html)
300 }
301}
302
303// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
304// This function can be easily modified to change the appearance and content
305func generateKalamarPluginHTML(data TemplateData) string {
306 html := `<!DOCTYPE html>
307<html lang="en">
308<head>
309 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200310 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200311 <script src="` + data.SDK + `"
312 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200313</head>
314<body>
315 <div class="container">
316 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200317 <p>` + data.Description + `</p>`
318
319 if data.MapID != "" {
320 html += `<p>Map ID: ` + data.MapID + `</p>`
321 }
322
323 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200324 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
325 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
326 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200327
Akronc471c0a2025-06-04 11:56:22 +0200328 <h2>Available API Endpoints</h2>
329 <dl>
Akron40aaa632025-06-03 17:57:52 +0200330
Akronc376dcc2025-06-04 17:00:18 +0200331 <dt><tt><strong>GET</strong> /:map</tt></dt>
332 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200333
334 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200335 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
336
Akronc471c0a2025-06-04 11:56:22 +0200337 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200338
339 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200340 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200341
Akrondab27112025-06-05 13:52:43 +0200342 for _, m := range data.Mappings {
343 html += `<dt><tt>` + m.ID + `</tt></dt>`
344 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200345 }
346
347 html += `
Akrondab27112025-06-05 13:52:43 +0200348 </dl>`
Akron06d21f02025-06-04 14:36:07 +0200349
Akronc376dcc2025-06-04 17:00:18 +0200350 if data.MapID != "" {
351 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200352 <!-- activates/deactivates Mapper. -->
353
354 let data = {
355 'action' : 'pipe',
Akronc376dcc2025-06-04 17:00:18 +0200356 'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/` + data.MapID + `/query'
Akron06d21f02025-06-04 14:36:07 +0200357 };
358
359 function pluginit (p) {
360 p.onMessage = function(msg) {
361 if (msg.key == 'termmapper') {
362 if (msg.value) {
363 data['job'] = 'add';
364 }
365 else {
366 data['job'] = 'del';
367 };
368 KorAPlugin.sendMsg(data);
369 };
370 };
371 };
Akronc376dcc2025-06-04 17:00:18 +0200372 </script>`
373 }
374
375 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200376</html>`
377
378 return html
379}
Akron14678dc2025-06-05 13:01:38 +0200380
381// expandGlobs expands glob patterns in the slice of file paths
382// Returns the expanded list of files or an error if glob expansion fails
383func expandGlobs(patterns []string) ([]string, error) {
384 var expanded []string
385
386 for _, pattern := range patterns {
387 // Use filepath.Glob which works cross-platform
388 matches, err := filepath.Glob(pattern)
389 if err != nil {
390 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
391 }
392
393 // If no matches found, treat as literal filename (consistent with shell behavior)
394 if len(matches) == 0 {
395 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
396 expanded = append(expanded, pattern)
397 } else {
398 expanded = append(expanded, matches...)
399 }
400 }
401
402 return expanded, nil
403}