blob: 2a2f7e3217bd143c0e487521f22a9f2dfd79de51 [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 {
Akrone1cff7c2025-06-04 18:43:32 +020024 Port int `kong:"short='p',default='8080',help='Port to listen on'"`
25 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'"`
27 LogLevel string `kong:"short='l',default='info',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
Akron49ceeb42025-05-23 17:46:01 +020082 // Set up logging
Akron1fc750e2025-05-26 16:54:18 +020083 setupLogger(cfg.LogLevel)
Akron49ceeb42025-05-23 17:46:01 +020084
Akrone1cff7c2025-06-04 18:43:32 +020085 // Load configuration from multiple sources
86 yamlConfig, err := config.LoadFromSources(cfg.Config, cfg.Mappings)
Akrona00d4752025-05-26 17:34:36 +020087 if err != nil {
88 log.Fatal().Err(err).Msg("Failed to load configuration")
89 }
90
Akron49ceeb42025-05-23 17:46:01 +020091 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +020092 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +020093 if err != nil {
94 log.Fatal().Err(err).Msg("Failed to create mapper")
95 }
96
97 // Create fiber app
98 app := fiber.New(fiber.Config{
99 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200100 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +0200101 })
102
103 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200104 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200105
106 // Start server
107 go func() {
Akron1fc750e2025-05-26 16:54:18 +0200108 log.Info().Int("port", cfg.Port).Msg("Starting server")
109 if err := app.Listen(fmt.Sprintf(":%d", cfg.Port)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200110 log.Fatal().Err(err).Msg("Server error")
111 }
112 }()
113
114 // Wait for interrupt signal
115 sigChan := make(chan os.Signal, 1)
116 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
117 <-sigChan
118
119 // Graceful shutdown
120 log.Info().Msg("Shutting down server")
121 if err := app.Shutdown(); err != nil {
122 log.Error().Err(err).Msg("Error during shutdown")
123 }
124}
125
Akron06d21f02025-06-04 14:36:07 +0200126func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200127 // Health check endpoint
128 app.Get("/health", func(c *fiber.Ctx) error {
129 return c.SendString("OK")
130 })
131
132 // Transformation endpoint
133 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200134
135 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200136 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200137 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200138}
139
140func handleTransform(m *mapper.Mapper) fiber.Handler {
141 return func(c *fiber.Ctx) error {
142 // Get parameters
143 mapID := c.Params("map")
144 dir := c.Query("dir", "atob")
145 foundryA := c.Query("foundryA", "")
146 foundryB := c.Query("foundryB", "")
147 layerA := c.Query("layerA", "")
148 layerB := c.Query("layerB", "")
149
Akron74e1c072025-05-26 14:38:25 +0200150 // Validate input parameters
151 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
152 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
153 "error": err.Error(),
154 })
155 }
156
Akron49ceeb42025-05-23 17:46:01 +0200157 // Validate direction
158 if dir != "atob" && dir != "btoa" {
159 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
160 "error": "invalid direction, must be 'atob' or 'btoa'",
161 })
162 }
163
164 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200165 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200166 if err := c.BodyParser(&jsonData); err != nil {
167 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
168 "error": "invalid JSON in request body",
169 })
170 }
171
Akrona1a183f2025-05-26 17:47:33 +0200172 // Parse direction
173 direction, err := mapper.ParseDirection(dir)
174 if err != nil {
175 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
176 "error": err.Error(),
177 })
178 }
179
Akron49ceeb42025-05-23 17:46:01 +0200180 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200181 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200182 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200183 FoundryA: foundryA,
184 FoundryB: foundryB,
185 LayerA: layerA,
186 LayerB: layerB,
187 }, jsonData)
188
189 if err != nil {
190 log.Error().Err(err).
191 Str("mapID", mapID).
192 Str("direction", dir).
193 Msg("Failed to apply mappings")
194
195 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
196 "error": err.Error(),
197 })
198 }
199
200 return c.JSON(result)
201 }
202}
Akron74e1c072025-05-26 14:38:25 +0200203
204// validateInput checks if the input parameters are valid
205func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200206 // Define parameter checks
207 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200208 name string
209 value string
210 }{
211 {"mapID", mapID},
212 {"dir", dir},
213 {"foundryA", foundryA},
214 {"foundryB", foundryB},
215 {"layerA", layerA},
216 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200217 }
218
219 for _, param := range params {
220 // Check input lengths
221 if len(param.value) > maxParamLength {
222 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
223 }
224 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200225 if strings.ContainsAny(param.value, "<>{}[]\\") {
226 return fmt.Errorf("%s contains invalid characters", param.name)
227 }
228 }
229
Akron69d43bf2025-05-26 17:09:00 +0200230 if len(body) > maxInputLength {
231 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
232 }
233
Akron74e1c072025-05-26 14:38:25 +0200234 return nil
235}
Akron40aaa632025-06-03 17:57:52 +0200236
Akron06d21f02025-06-04 14:36:07 +0200237func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200238 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200239 mapID := c.Params("map")
240
Akron40aaa632025-06-03 17:57:52 +0200241 // Get list of available mapping IDs
242 var mappingIDs []string
243 for _, list := range yamlConfig.Lists {
244 mappingIDs = append(mappingIDs, list.ID)
245 }
246
Akron06d21f02025-06-04 14:36:07 +0200247 // Use values from config (defaults are already applied during parsing)
248 server := yamlConfig.Server
249 sdk := yamlConfig.SDK
250
Akron40aaa632025-06-03 17:57:52 +0200251 // Prepare template data
252 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200253 Title: config.Title,
254 Version: config.Version,
255 Hash: config.Buildhash,
256 Date: config.Buildtime,
257 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200258 Server: server,
259 SDK: sdk,
Akronc376dcc2025-06-04 17:00:18 +0200260 MapID: mapID,
Akron40aaa632025-06-03 17:57:52 +0200261 MappingIDs: mappingIDs,
262 }
263
264 // Generate HTML
265 html := generateKalamarPluginHTML(data)
266
267 c.Set("Content-Type", "text/html")
268 return c.SendString(html)
269 }
270}
271
272// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
273// This function can be easily modified to change the appearance and content
274func generateKalamarPluginHTML(data TemplateData) string {
275 html := `<!DOCTYPE html>
276<html lang="en">
277<head>
278 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200279 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200280 <script src="` + data.SDK + `"
281 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200282</head>
283<body>
284 <div class="container">
285 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200286 <p>` + data.Description + `</p>`
287
288 if data.MapID != "" {
289 html += `<p>Map ID: ` + data.MapID + `</p>`
290 }
291
292 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200293 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
294 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
295 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200296
Akronc471c0a2025-06-04 11:56:22 +0200297 <h2>Available API Endpoints</h2>
298 <dl>
Akron40aaa632025-06-03 17:57:52 +0200299
Akronc376dcc2025-06-04 17:00:18 +0200300 <dt><tt><strong>GET</strong> /:map</tt></dt>
301 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200302
303 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200304 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
305
Akronc471c0a2025-06-04 11:56:22 +0200306 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200307
308 <h2>Available Term Mappings</h2>
Akronc471c0a2025-06-04 11:56:22 +0200309 <ul>`
Akron40aaa632025-06-03 17:57:52 +0200310
311 for _, id := range data.MappingIDs {
312 html += `
313 <li>` + id + `</li>`
314 }
315
316 html += `
Akronc376dcc2025-06-04 17:00:18 +0200317 </ul>`
Akron06d21f02025-06-04 14:36:07 +0200318
Akronc376dcc2025-06-04 17:00:18 +0200319 if data.MapID != "" {
320 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200321 <!-- activates/deactivates Mapper. -->
322
323 let data = {
324 'action' : 'pipe',
Akronc376dcc2025-06-04 17:00:18 +0200325 'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/` + data.MapID + `/query'
Akron06d21f02025-06-04 14:36:07 +0200326 };
327
328 function pluginit (p) {
329 p.onMessage = function(msg) {
330 if (msg.key == 'termmapper') {
331 if (msg.value) {
332 data['job'] = 'add';
333 }
334 else {
335 data['job'] = 'del';
336 };
337 KorAPlugin.sendMsg(data);
338 };
339 };
340 };
Akronc376dcc2025-06-04 17:00:18 +0200341 </script>`
342 }
343
344 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200345</html>`
346
347 return html
348}