blob: e9455726892a2f0ff4bc91862632939b4c4fe5c7 [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
Akron40aaa632025-06-03 17:57:52 +020031// TemplateData holds data for the Kalamar plugin template
32type TemplateData struct {
33 Title string
34 Version string
Akronfc77b5e2025-06-04 11:44:43 +020035 Hash string
36 Date string
Akron40aaa632025-06-03 17:57:52 +020037 Description string
Akron06d21f02025-06-04 14:36:07 +020038 Server string
39 SDK string
Akronc376dcc2025-06-04 17:00:18 +020040 MapID string
Akron40aaa632025-06-03 17:57:52 +020041 MappingIDs []string
42}
43
Akrona00d4752025-05-26 17:34:36 +020044func parseConfig() *appConfig {
45 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020046
47 desc := config.Description
48 desc += " [" + config.Version + "]"
49
Akron1fc750e2025-05-26 16:54:18 +020050 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020051 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020052 kong.UsageOnError(),
53 )
54 if ctx.Error != nil {
55 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020056 os.Exit(1)
57 }
Akron49ceeb42025-05-23 17:46:01 +020058 return cfg
59}
60
61func setupLogger(level string) {
62 // Parse log level
63 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
64 if err != nil {
65 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
66 lvl = zerolog.InfoLevel
67 }
68
69 // Configure zerolog
70 zerolog.SetGlobalLevel(lvl)
71 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
72}
73
74func main() {
75 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020076 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020077
Akrone1cff7c2025-06-04 18:43:32 +020078 // Validate command line arguments
79 if cfg.Config == "" && len(cfg.Mappings) == 0 {
80 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
81 }
82
Akron14678dc2025-06-05 13:01:38 +020083 // Expand glob patterns in mapping files
84 expandedMappings, err := expandGlobs(cfg.Mappings)
85 if err != nil {
86 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
87 }
88
Akrone1cff7c2025-06-04 18:43:32 +020089 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +020090 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +020091 if err != nil {
92 log.Fatal().Err(err).Msg("Failed to load configuration")
93 }
94
Akrona8a66ce2025-06-05 10:50:17 +020095 finalPort := yamlConfig.Port
96 finalLogLevel := yamlConfig.LogLevel
97
98 // Use command line values if provided (they override config file)
99 if cfg.Port != nil {
100 finalPort = *cfg.Port
101 }
102 if cfg.LogLevel != nil {
103 finalLogLevel = *cfg.LogLevel
104 }
105
106 // Set up logging with the final log level
107 setupLogger(finalLogLevel)
108
Akron49ceeb42025-05-23 17:46:01 +0200109 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200110 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200111 if err != nil {
112 log.Fatal().Err(err).Msg("Failed to create mapper")
113 }
114
115 // Create fiber app
116 app := fiber.New(fiber.Config{
117 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200118 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +0200119 })
120
121 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200122 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200123
124 // Start server
125 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200126 log.Info().Int("port", finalPort).Msg("Starting server")
127 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200128 log.Fatal().Err(err).Msg("Server error")
129 }
130 }()
131
132 // Wait for interrupt signal
133 sigChan := make(chan os.Signal, 1)
134 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
135 <-sigChan
136
137 // Graceful shutdown
138 log.Info().Msg("Shutting down server")
139 if err := app.Shutdown(); err != nil {
140 log.Error().Err(err).Msg("Error during shutdown")
141 }
142}
143
Akron06d21f02025-06-04 14:36:07 +0200144func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200145 // Health check endpoint
146 app.Get("/health", func(c *fiber.Ctx) error {
147 return c.SendString("OK")
148 })
149
150 // Transformation endpoint
151 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200152
153 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200154 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200155 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200156}
157
158func handleTransform(m *mapper.Mapper) fiber.Handler {
159 return func(c *fiber.Ctx) error {
160 // Get parameters
161 mapID := c.Params("map")
162 dir := c.Query("dir", "atob")
163 foundryA := c.Query("foundryA", "")
164 foundryB := c.Query("foundryB", "")
165 layerA := c.Query("layerA", "")
166 layerB := c.Query("layerB", "")
167
Akron74e1c072025-05-26 14:38:25 +0200168 // Validate input parameters
169 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
170 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
171 "error": err.Error(),
172 })
173 }
174
Akron49ceeb42025-05-23 17:46:01 +0200175 // Validate direction
176 if dir != "atob" && dir != "btoa" {
177 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
178 "error": "invalid direction, must be 'atob' or 'btoa'",
179 })
180 }
181
182 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200183 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200184 if err := c.BodyParser(&jsonData); err != nil {
185 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
186 "error": "invalid JSON in request body",
187 })
188 }
189
Akrona1a183f2025-05-26 17:47:33 +0200190 // Parse direction
191 direction, err := mapper.ParseDirection(dir)
192 if err != nil {
193 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
194 "error": err.Error(),
195 })
196 }
197
Akron49ceeb42025-05-23 17:46:01 +0200198 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200199 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200200 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200201 FoundryA: foundryA,
202 FoundryB: foundryB,
203 LayerA: layerA,
204 LayerB: layerB,
205 }, jsonData)
206
207 if err != nil {
208 log.Error().Err(err).
209 Str("mapID", mapID).
210 Str("direction", dir).
211 Msg("Failed to apply mappings")
212
213 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
214 "error": err.Error(),
215 })
216 }
217
218 return c.JSON(result)
219 }
220}
Akron74e1c072025-05-26 14:38:25 +0200221
222// validateInput checks if the input parameters are valid
223func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200224 // Define parameter checks
225 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200226 name string
227 value string
228 }{
229 {"mapID", mapID},
230 {"dir", dir},
231 {"foundryA", foundryA},
232 {"foundryB", foundryB},
233 {"layerA", layerA},
234 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200235 }
236
237 for _, param := range params {
238 // Check input lengths
239 if len(param.value) > maxParamLength {
240 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
241 }
242 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200243 if strings.ContainsAny(param.value, "<>{}[]\\") {
244 return fmt.Errorf("%s contains invalid characters", param.name)
245 }
246 }
247
Akron69d43bf2025-05-26 17:09:00 +0200248 if len(body) > maxInputLength {
249 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
250 }
251
Akron74e1c072025-05-26 14:38:25 +0200252 return nil
253}
Akron40aaa632025-06-03 17:57:52 +0200254
Akron06d21f02025-06-04 14:36:07 +0200255func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200256 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200257 mapID := c.Params("map")
258
Akron40aaa632025-06-03 17:57:52 +0200259 // Get list of available mapping IDs
260 var mappingIDs []string
261 for _, list := range yamlConfig.Lists {
262 mappingIDs = append(mappingIDs, list.ID)
263 }
264
Akron06d21f02025-06-04 14:36:07 +0200265 // Use values from config (defaults are already applied during parsing)
266 server := yamlConfig.Server
267 sdk := yamlConfig.SDK
268
Akron40aaa632025-06-03 17:57:52 +0200269 // Prepare template data
270 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200271 Title: config.Title,
272 Version: config.Version,
273 Hash: config.Buildhash,
274 Date: config.Buildtime,
275 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200276 Server: server,
277 SDK: sdk,
Akronc376dcc2025-06-04 17:00:18 +0200278 MapID: mapID,
Akron40aaa632025-06-03 17:57:52 +0200279 MappingIDs: mappingIDs,
280 }
281
282 // Generate HTML
283 html := generateKalamarPluginHTML(data)
284
285 c.Set("Content-Type", "text/html")
286 return c.SendString(html)
287 }
288}
289
290// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
291// This function can be easily modified to change the appearance and content
292func generateKalamarPluginHTML(data TemplateData) string {
293 html := `<!DOCTYPE html>
294<html lang="en">
295<head>
296 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200297 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200298 <script src="` + data.SDK + `"
299 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200300</head>
301<body>
302 <div class="container">
303 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200304 <p>` + data.Description + `</p>`
305
306 if data.MapID != "" {
307 html += `<p>Map ID: ` + data.MapID + `</p>`
308 }
309
310 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200311 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
312 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
313 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200314
Akronc471c0a2025-06-04 11:56:22 +0200315 <h2>Available API Endpoints</h2>
316 <dl>
Akron40aaa632025-06-03 17:57:52 +0200317
Akronc376dcc2025-06-04 17:00:18 +0200318 <dt><tt><strong>GET</strong> /:map</tt></dt>
319 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200320
321 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200322 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
323
Akronc471c0a2025-06-04 11:56:22 +0200324 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200325
326 <h2>Available Term Mappings</h2>
Akronc471c0a2025-06-04 11:56:22 +0200327 <ul>`
Akron40aaa632025-06-03 17:57:52 +0200328
329 for _, id := range data.MappingIDs {
330 html += `
331 <li>` + id + `</li>`
332 }
333
334 html += `
Akronc376dcc2025-06-04 17:00:18 +0200335 </ul>`
Akron06d21f02025-06-04 14:36:07 +0200336
Akronc376dcc2025-06-04 17:00:18 +0200337 if data.MapID != "" {
338 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200339 <!-- activates/deactivates Mapper. -->
340
341 let data = {
342 'action' : 'pipe',
Akronc376dcc2025-06-04 17:00:18 +0200343 'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/` + data.MapID + `/query'
Akron06d21f02025-06-04 14:36:07 +0200344 };
345
346 function pluginit (p) {
347 p.onMessage = function(msg) {
348 if (msg.key == 'termmapper') {
349 if (msg.value) {
350 data['job'] = 'add';
351 }
352 else {
353 data['job'] = 'del';
354 };
355 KorAPlugin.sendMsg(data);
356 };
357 };
358 };
Akronc376dcc2025-06-04 17:00:18 +0200359 </script>`
360 }
361
362 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200363</html>`
364
365 return html
366}
Akron14678dc2025-06-05 13:01:38 +0200367
368// expandGlobs expands glob patterns in the slice of file paths
369// Returns the expanded list of files or an error if glob expansion fails
370func expandGlobs(patterns []string) ([]string, error) {
371 var expanded []string
372
373 for _, pattern := range patterns {
374 // Use filepath.Glob which works cross-platform
375 matches, err := filepath.Glob(pattern)
376 if err != nil {
377 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
378 }
379
380 // If no matches found, treat as literal filename (consistent with shell behavior)
381 if len(matches) == 0 {
382 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
383 expanded = append(expanded, pattern)
384 } else {
385 expanded = append(expanded, matches...)
386 }
387 }
388
389 return expanded, nil
390}