blob: aa940ac1f6d271a97b50ce8db0f87604627b3a0b [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"
7 "strings"
8 "syscall"
9
Akrona00d4752025-05-26 17:34:36 +020010 "github.com/KorAP/KoralPipe-TermMapper/config"
Akronfa55bb22025-05-26 15:10:42 +020011 "github.com/KorAP/KoralPipe-TermMapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020012 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020013 "github.com/gofiber/fiber/v2"
14 "github.com/rs/zerolog"
15 "github.com/rs/zerolog/log"
16)
17
Akron74e1c072025-05-26 14:38:25 +020018const (
19 maxInputLength = 1024 * 1024 // 1MB
20 maxParamLength = 1024 // 1KB
21)
22
Akrona00d4752025-05-26 17:34:36 +020023type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020024 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020025 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
26 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load'"`
Akrona8a66ce2025-06-05 10:50:17 +020027 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020028}
29
Akron40aaa632025-06-03 17:57:52 +020030// TemplateData holds data for the Kalamar plugin template
31type TemplateData struct {
32 Title string
33 Version string
Akronfc77b5e2025-06-04 11:44:43 +020034 Hash string
35 Date string
Akron40aaa632025-06-03 17:57:52 +020036 Description string
Akron06d21f02025-06-04 14:36:07 +020037 Server string
38 SDK string
Akronc376dcc2025-06-04 17:00:18 +020039 MapID string
Akron40aaa632025-06-03 17:57:52 +020040 MappingIDs []string
41}
42
Akrona00d4752025-05-26 17:34:36 +020043func parseConfig() *appConfig {
44 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020045
46 desc := config.Description
47 desc += " [" + config.Version + "]"
48
Akron1fc750e2025-05-26 16:54:18 +020049 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020050 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020051 kong.UsageOnError(),
52 )
53 if ctx.Error != nil {
54 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020055 os.Exit(1)
56 }
Akron49ceeb42025-05-23 17:46:01 +020057 return cfg
58}
59
60func setupLogger(level string) {
61 // Parse log level
62 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
63 if err != nil {
64 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
65 lvl = zerolog.InfoLevel
66 }
67
68 // Configure zerolog
69 zerolog.SetGlobalLevel(lvl)
70 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
71}
72
73func main() {
74 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020075 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020076
Akrone1cff7c2025-06-04 18:43:32 +020077 // Validate command line arguments
78 if cfg.Config == "" && len(cfg.Mappings) == 0 {
79 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
80 }
81
Akrone1cff7c2025-06-04 18:43:32 +020082 // Load configuration from multiple sources
83 yamlConfig, err := config.LoadFromSources(cfg.Config, cfg.Mappings)
Akrona00d4752025-05-26 17:34:36 +020084 if err != nil {
85 log.Fatal().Err(err).Msg("Failed to load configuration")
86 }
87
Akrona8a66ce2025-06-05 10:50:17 +020088 finalPort := yamlConfig.Port
89 finalLogLevel := yamlConfig.LogLevel
90
91 // Use command line values if provided (they override config file)
92 if cfg.Port != nil {
93 finalPort = *cfg.Port
94 }
95 if cfg.LogLevel != nil {
96 finalLogLevel = *cfg.LogLevel
97 }
98
99 // Set up logging with the final log level
100 setupLogger(finalLogLevel)
101
Akron49ceeb42025-05-23 17:46:01 +0200102 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200103 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200104 if err != nil {
105 log.Fatal().Err(err).Msg("Failed to create mapper")
106 }
107
108 // Create fiber app
109 app := fiber.New(fiber.Config{
110 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200111 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +0200112 })
113
114 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200115 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200116
117 // Start server
118 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200119 log.Info().Int("port", finalPort).Msg("Starting server")
120 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200121 log.Fatal().Err(err).Msg("Server error")
122 }
123 }()
124
125 // Wait for interrupt signal
126 sigChan := make(chan os.Signal, 1)
127 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
128 <-sigChan
129
130 // Graceful shutdown
131 log.Info().Msg("Shutting down server")
132 if err := app.Shutdown(); err != nil {
133 log.Error().Err(err).Msg("Error during shutdown")
134 }
135}
136
Akron06d21f02025-06-04 14:36:07 +0200137func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200138 // Health check endpoint
139 app.Get("/health", func(c *fiber.Ctx) error {
140 return c.SendString("OK")
141 })
142
143 // Transformation endpoint
144 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200145
146 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200147 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200148 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200149}
150
151func handleTransform(m *mapper.Mapper) fiber.Handler {
152 return func(c *fiber.Ctx) error {
153 // Get parameters
154 mapID := c.Params("map")
155 dir := c.Query("dir", "atob")
156 foundryA := c.Query("foundryA", "")
157 foundryB := c.Query("foundryB", "")
158 layerA := c.Query("layerA", "")
159 layerB := c.Query("layerB", "")
160
Akron74e1c072025-05-26 14:38:25 +0200161 // Validate input parameters
162 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
163 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
164 "error": err.Error(),
165 })
166 }
167
Akron49ceeb42025-05-23 17:46:01 +0200168 // Validate direction
169 if dir != "atob" && dir != "btoa" {
170 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
171 "error": "invalid direction, must be 'atob' or 'btoa'",
172 })
173 }
174
175 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200176 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200177 if err := c.BodyParser(&jsonData); err != nil {
178 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
179 "error": "invalid JSON in request body",
180 })
181 }
182
Akrona1a183f2025-05-26 17:47:33 +0200183 // Parse direction
184 direction, err := mapper.ParseDirection(dir)
185 if 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 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200192 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200193 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200194 FoundryA: foundryA,
195 FoundryB: foundryB,
196 LayerA: layerA,
197 LayerB: layerB,
198 }, jsonData)
199
200 if err != nil {
201 log.Error().Err(err).
202 Str("mapID", mapID).
203 Str("direction", dir).
204 Msg("Failed to apply mappings")
205
206 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
207 "error": err.Error(),
208 })
209 }
210
211 return c.JSON(result)
212 }
213}
Akron74e1c072025-05-26 14:38:25 +0200214
215// validateInput checks if the input parameters are valid
216func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200217 // Define parameter checks
218 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200219 name string
220 value string
221 }{
222 {"mapID", mapID},
223 {"dir", dir},
224 {"foundryA", foundryA},
225 {"foundryB", foundryB},
226 {"layerA", layerA},
227 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200228 }
229
230 for _, param := range params {
231 // Check input lengths
232 if len(param.value) > maxParamLength {
233 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
234 }
235 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200236 if strings.ContainsAny(param.value, "<>{}[]\\") {
237 return fmt.Errorf("%s contains invalid characters", param.name)
238 }
239 }
240
Akron69d43bf2025-05-26 17:09:00 +0200241 if len(body) > maxInputLength {
242 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
243 }
244
Akron74e1c072025-05-26 14:38:25 +0200245 return nil
246}
Akron40aaa632025-06-03 17:57:52 +0200247
Akron06d21f02025-06-04 14:36:07 +0200248func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200249 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200250 mapID := c.Params("map")
251
Akron40aaa632025-06-03 17:57:52 +0200252 // Get list of available mapping IDs
253 var mappingIDs []string
254 for _, list := range yamlConfig.Lists {
255 mappingIDs = append(mappingIDs, list.ID)
256 }
257
Akron06d21f02025-06-04 14:36:07 +0200258 // Use values from config (defaults are already applied during parsing)
259 server := yamlConfig.Server
260 sdk := yamlConfig.SDK
261
Akron40aaa632025-06-03 17:57:52 +0200262 // Prepare template data
263 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200264 Title: config.Title,
265 Version: config.Version,
266 Hash: config.Buildhash,
267 Date: config.Buildtime,
268 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200269 Server: server,
270 SDK: sdk,
Akronc376dcc2025-06-04 17:00:18 +0200271 MapID: mapID,
Akron40aaa632025-06-03 17:57:52 +0200272 MappingIDs: mappingIDs,
273 }
274
275 // Generate HTML
276 html := generateKalamarPluginHTML(data)
277
278 c.Set("Content-Type", "text/html")
279 return c.SendString(html)
280 }
281}
282
283// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
284// This function can be easily modified to change the appearance and content
285func generateKalamarPluginHTML(data TemplateData) string {
286 html := `<!DOCTYPE html>
287<html lang="en">
288<head>
289 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200290 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200291 <script src="` + data.SDK + `"
292 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200293</head>
294<body>
295 <div class="container">
296 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200297 <p>` + data.Description + `</p>`
298
299 if data.MapID != "" {
300 html += `<p>Map ID: ` + data.MapID + `</p>`
301 }
302
303 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200304 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
305 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
306 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200307
Akronc471c0a2025-06-04 11:56:22 +0200308 <h2>Available API Endpoints</h2>
309 <dl>
Akron40aaa632025-06-03 17:57:52 +0200310
Akronc376dcc2025-06-04 17:00:18 +0200311 <dt><tt><strong>GET</strong> /:map</tt></dt>
312 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200313
314 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200315 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
316
Akronc471c0a2025-06-04 11:56:22 +0200317 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200318
319 <h2>Available Term Mappings</h2>
Akronc471c0a2025-06-04 11:56:22 +0200320 <ul>`
Akron40aaa632025-06-03 17:57:52 +0200321
322 for _, id := range data.MappingIDs {
323 html += `
324 <li>` + id + `</li>`
325 }
326
327 html += `
Akronc376dcc2025-06-04 17:00:18 +0200328 </ul>`
Akron06d21f02025-06-04 14:36:07 +0200329
Akronc376dcc2025-06-04 17:00:18 +0200330 if data.MapID != "" {
331 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200332 <!-- activates/deactivates Mapper. -->
333
334 let data = {
335 'action' : 'pipe',
Akronc376dcc2025-06-04 17:00:18 +0200336 'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/` + data.MapID + `/query'
Akron06d21f02025-06-04 14:36:07 +0200337 };
338
339 function pluginit (p) {
340 p.onMessage = function(msg) {
341 if (msg.key == 'termmapper') {
342 if (msg.value) {
343 data['job'] = 'add';
344 }
345 else {
346 data['job'] = 'del';
347 };
348 KorAPlugin.sendMsg(data);
349 };
350 };
351 };
Akronc376dcc2025-06-04 17:00:18 +0200352 </script>`
353 }
354
355 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200356</html>`
357
358 return html
359}