blob: 4c868b569142f97d3ce32cc3e8a56adff1a856d3 [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
166 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200167 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200168 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200169}
170
171func handleTransform(m *mapper.Mapper) fiber.Handler {
172 return func(c *fiber.Ctx) error {
173 // Get parameters
174 mapID := c.Params("map")
175 dir := c.Query("dir", "atob")
176 foundryA := c.Query("foundryA", "")
177 foundryB := c.Query("foundryB", "")
178 layerA := c.Query("layerA", "")
179 layerB := c.Query("layerB", "")
180
Akron74e1c072025-05-26 14:38:25 +0200181 // Validate input parameters
182 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
183 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
184 "error": err.Error(),
185 })
186 }
187
Akron49ceeb42025-05-23 17:46:01 +0200188 // Validate direction
189 if dir != "atob" && dir != "btoa" {
190 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
191 "error": "invalid direction, must be 'atob' or 'btoa'",
192 })
193 }
194
195 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200196 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200197 if err := c.BodyParser(&jsonData); err != nil {
198 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
199 "error": "invalid JSON in request body",
200 })
201 }
202
Akrona1a183f2025-05-26 17:47:33 +0200203 // Parse direction
204 direction, err := mapper.ParseDirection(dir)
205 if err != nil {
206 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
207 "error": err.Error(),
208 })
209 }
210
Akron49ceeb42025-05-23 17:46:01 +0200211 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200212 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200213 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200214 FoundryA: foundryA,
215 FoundryB: foundryB,
216 LayerA: layerA,
217 LayerB: layerB,
218 }, jsonData)
219
220 if err != nil {
221 log.Error().Err(err).
222 Str("mapID", mapID).
223 Str("direction", dir).
224 Msg("Failed to apply mappings")
225
226 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
227 "error": err.Error(),
228 })
229 }
230
231 return c.JSON(result)
232 }
233}
Akron74e1c072025-05-26 14:38:25 +0200234
235// validateInput checks if the input parameters are valid
236func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200237 // Define parameter checks
238 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200239 name string
240 value string
241 }{
242 {"mapID", mapID},
243 {"dir", dir},
244 {"foundryA", foundryA},
245 {"foundryB", foundryB},
246 {"layerA", layerA},
247 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200248 }
249
250 for _, param := range params {
251 // Check input lengths
252 if len(param.value) > maxParamLength {
253 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
254 }
255 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200256 if strings.ContainsAny(param.value, "<>{}[]\\") {
257 return fmt.Errorf("%s contains invalid characters", param.name)
258 }
259 }
260
Akron69d43bf2025-05-26 17:09:00 +0200261 if len(body) > maxInputLength {
262 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
263 }
264
Akron74e1c072025-05-26 14:38:25 +0200265 return nil
266}
Akron40aaa632025-06-03 17:57:52 +0200267
Akron06d21f02025-06-04 14:36:07 +0200268func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200269 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200270 mapID := c.Params("map")
271
Akrondab27112025-06-05 13:52:43 +0200272 // Get list of available mappings
273 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200274 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200275 mappings = append(mappings, TemplateMapping{
276 ID: list.ID,
277 Description: list.Description,
278 })
Akron40aaa632025-06-03 17:57:52 +0200279 }
280
Akron06d21f02025-06-04 14:36:07 +0200281 // Use values from config (defaults are already applied during parsing)
282 server := yamlConfig.Server
283 sdk := yamlConfig.SDK
284
Akron40aaa632025-06-03 17:57:52 +0200285 // Prepare template data
286 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200287 Title: config.Title,
288 Version: config.Version,
289 Hash: config.Buildhash,
290 Date: config.Buildtime,
291 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200292 Server: server,
293 SDK: sdk,
Akron2ac2ec02025-06-05 15:26:42 +0200294 ServiceURL: yamlConfig.ServiceURL,
Akronc376dcc2025-06-04 17:00:18 +0200295 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200296 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200297 }
298
299 // Generate HTML
300 html := generateKalamarPluginHTML(data)
301
302 c.Set("Content-Type", "text/html")
303 return c.SendString(html)
304 }
305}
306
307// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
308// This function can be easily modified to change the appearance and content
309func generateKalamarPluginHTML(data TemplateData) string {
310 html := `<!DOCTYPE html>
311<html lang="en">
312<head>
313 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200314 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200315 <script src="` + data.SDK + `"
316 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200317</head>
318<body>
319 <div class="container">
320 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200321 <p>` + data.Description + `</p>`
322
323 if data.MapID != "" {
324 html += `<p>Map ID: ` + data.MapID + `</p>`
325 }
326
327 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200328 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
329 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
330 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200331
Akronc471c0a2025-06-04 11:56:22 +0200332 <h2>Available API Endpoints</h2>
333 <dl>
Akron40aaa632025-06-03 17:57:52 +0200334
Akronc376dcc2025-06-04 17:00:18 +0200335 <dt><tt><strong>GET</strong> /:map</tt></dt>
336 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200337
338 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200339 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
340
Akronc471c0a2025-06-04 11:56:22 +0200341 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200342
343 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200344 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200345
Akrondab27112025-06-05 13:52:43 +0200346 for _, m := range data.Mappings {
347 html += `<dt><tt>` + m.ID + `</tt></dt>`
348 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200349 }
350
351 html += `
Akrondab27112025-06-05 13:52:43 +0200352 </dl>`
Akron06d21f02025-06-04 14:36:07 +0200353
Akronc376dcc2025-06-04 17:00:18 +0200354 if data.MapID != "" {
Akron80067202025-06-06 14:16:25 +0200355
356 serviceURL, err := url.Parse(data.ServiceURL)
357 if err != nil {
358 log.Warn().Err(err).Msg("Failed to join URL path")
359 }
360
361 // Use path.Join to normalize the path part
362 serviceURL.Path = path.Join(serviceURL.Path, data.MapID+"/query")
363
Akronc376dcc2025-06-04 17:00:18 +0200364 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200365 <!-- activates/deactivates Mapper. -->
366
367 let data = {
368 'action' : 'pipe',
Akron80067202025-06-06 14:16:25 +0200369 'service' : '` + serviceURL.String() + `'
Akron06d21f02025-06-04 14:36:07 +0200370 };
371
372 function pluginit (p) {
373 p.onMessage = function(msg) {
374 if (msg.key == 'termmapper') {
375 if (msg.value) {
376 data['job'] = 'add';
377 }
378 else {
379 data['job'] = 'del';
380 };
381 KorAPlugin.sendMsg(data);
382 };
383 };
384 };
Akronc376dcc2025-06-04 17:00:18 +0200385 </script>`
386 }
387
388 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200389</html>`
390
391 return html
392}
Akron14678dc2025-06-05 13:01:38 +0200393
394// expandGlobs expands glob patterns in the slice of file paths
395// Returns the expanded list of files or an error if glob expansion fails
396func expandGlobs(patterns []string) ([]string, error) {
397 var expanded []string
398
399 for _, pattern := range patterns {
400 // Use filepath.Glob which works cross-platform
401 matches, err := filepath.Glob(pattern)
402 if err != nil {
403 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
404 }
405
406 // If no matches found, treat as literal filename (consistent with shell behavior)
407 if len(matches) == 0 {
408 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
409 expanded = append(expanded, pattern)
410 } else {
411 expanded = append(expanded, matches...)
412 }
413 }
414
415 return expanded, nil
416}