blob: ec8b93dbfedeb3f35e86aa6c85f46a165caf469e [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 {
Akron1fc750e2025-05-26 16:54:18 +020024 Port int `kong:"short='p',default='8080',help='Port to listen on'"`
25 Config string `kong:"short='c',required,help='YAML configuration file containing mapping directives'"`
26 LogLevel string `kong:"short='l',default='info',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020027}
28
Akron40aaa632025-06-03 17:57:52 +020029// TemplateData holds data for the Kalamar plugin template
30type TemplateData struct {
31 Title string
32 Version string
Akronfc77b5e2025-06-04 11:44:43 +020033 Hash string
34 Date string
Akron40aaa632025-06-03 17:57:52 +020035 Description string
Akron06d21f02025-06-04 14:36:07 +020036 Server string
37 SDK string
Akronc376dcc2025-06-04 17:00:18 +020038 MapID string
Akron40aaa632025-06-03 17:57:52 +020039 MappingIDs []string
40}
41
Akrona00d4752025-05-26 17:34:36 +020042func parseConfig() *appConfig {
43 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020044
45 desc := config.Description
46 desc += " [" + config.Version + "]"
47
Akron1fc750e2025-05-26 16:54:18 +020048 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020049 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020050 kong.UsageOnError(),
51 )
52 if ctx.Error != nil {
53 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020054 os.Exit(1)
55 }
Akron49ceeb42025-05-23 17:46:01 +020056 return cfg
57}
58
59func setupLogger(level string) {
60 // Parse log level
61 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
62 if err != nil {
63 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
64 lvl = zerolog.InfoLevel
65 }
66
67 // Configure zerolog
68 zerolog.SetGlobalLevel(lvl)
69 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
70}
71
72func main() {
73 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020074 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020075
76 // Set up logging
Akron1fc750e2025-05-26 16:54:18 +020077 setupLogger(cfg.LogLevel)
Akron49ceeb42025-05-23 17:46:01 +020078
Akrona00d4752025-05-26 17:34:36 +020079 // Load configuration file
80 yamlConfig, err := config.LoadConfig(cfg.Config)
81 if err != nil {
82 log.Fatal().Err(err).Msg("Failed to load configuration")
83 }
84
Akron49ceeb42025-05-23 17:46:01 +020085 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +020086 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +020087 if err != nil {
88 log.Fatal().Err(err).Msg("Failed to create mapper")
89 }
90
91 // Create fiber app
92 app := fiber.New(fiber.Config{
93 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +020094 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +020095 })
96
97 // Set up routes
Akron40aaa632025-06-03 17:57:52 +020098 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +020099
100 // Start server
101 go func() {
Akron1fc750e2025-05-26 16:54:18 +0200102 log.Info().Int("port", cfg.Port).Msg("Starting server")
103 if err := app.Listen(fmt.Sprintf(":%d", cfg.Port)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200104 log.Fatal().Err(err).Msg("Server error")
105 }
106 }()
107
108 // Wait for interrupt signal
109 sigChan := make(chan os.Signal, 1)
110 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
111 <-sigChan
112
113 // Graceful shutdown
114 log.Info().Msg("Shutting down server")
115 if err := app.Shutdown(); err != nil {
116 log.Error().Err(err).Msg("Error during shutdown")
117 }
118}
119
Akron06d21f02025-06-04 14:36:07 +0200120func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200121 // Health check endpoint
122 app.Get("/health", func(c *fiber.Ctx) error {
123 return c.SendString("OK")
124 })
125
126 // Transformation endpoint
127 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200128
129 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200130 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200131 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200132}
133
134func handleTransform(m *mapper.Mapper) fiber.Handler {
135 return func(c *fiber.Ctx) error {
136 // Get parameters
137 mapID := c.Params("map")
138 dir := c.Query("dir", "atob")
139 foundryA := c.Query("foundryA", "")
140 foundryB := c.Query("foundryB", "")
141 layerA := c.Query("layerA", "")
142 layerB := c.Query("layerB", "")
143
Akron74e1c072025-05-26 14:38:25 +0200144 // Validate input parameters
145 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
146 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
147 "error": err.Error(),
148 })
149 }
150
Akron49ceeb42025-05-23 17:46:01 +0200151 // Validate direction
152 if dir != "atob" && dir != "btoa" {
153 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
154 "error": "invalid direction, must be 'atob' or 'btoa'",
155 })
156 }
157
158 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200159 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200160 if err := c.BodyParser(&jsonData); err != nil {
161 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
162 "error": "invalid JSON in request body",
163 })
164 }
165
Akrona1a183f2025-05-26 17:47:33 +0200166 // Parse direction
167 direction, err := mapper.ParseDirection(dir)
168 if err != nil {
169 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
170 "error": err.Error(),
171 })
172 }
173
Akron49ceeb42025-05-23 17:46:01 +0200174 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200175 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200176 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200177 FoundryA: foundryA,
178 FoundryB: foundryB,
179 LayerA: layerA,
180 LayerB: layerB,
181 }, jsonData)
182
183 if err != nil {
184 log.Error().Err(err).
185 Str("mapID", mapID).
186 Str("direction", dir).
187 Msg("Failed to apply mappings")
188
189 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
190 "error": err.Error(),
191 })
192 }
193
194 return c.JSON(result)
195 }
196}
Akron74e1c072025-05-26 14:38:25 +0200197
198// validateInput checks if the input parameters are valid
199func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200200 // Define parameter checks
201 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200202 name string
203 value string
204 }{
205 {"mapID", mapID},
206 {"dir", dir},
207 {"foundryA", foundryA},
208 {"foundryB", foundryB},
209 {"layerA", layerA},
210 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200211 }
212
213 for _, param := range params {
214 // Check input lengths
215 if len(param.value) > maxParamLength {
216 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
217 }
218 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200219 if strings.ContainsAny(param.value, "<>{}[]\\") {
220 return fmt.Errorf("%s contains invalid characters", param.name)
221 }
222 }
223
Akron69d43bf2025-05-26 17:09:00 +0200224 if len(body) > maxInputLength {
225 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
226 }
227
Akron74e1c072025-05-26 14:38:25 +0200228 return nil
229}
Akron40aaa632025-06-03 17:57:52 +0200230
Akron06d21f02025-06-04 14:36:07 +0200231func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200232 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200233 mapID := c.Params("map")
234
Akron40aaa632025-06-03 17:57:52 +0200235 // Get list of available mapping IDs
236 var mappingIDs []string
237 for _, list := range yamlConfig.Lists {
238 mappingIDs = append(mappingIDs, list.ID)
239 }
240
Akron06d21f02025-06-04 14:36:07 +0200241 // Use values from config (defaults are already applied during parsing)
242 server := yamlConfig.Server
243 sdk := yamlConfig.SDK
244
Akron40aaa632025-06-03 17:57:52 +0200245 // Prepare template data
246 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200247 Title: config.Title,
248 Version: config.Version,
249 Hash: config.Buildhash,
250 Date: config.Buildtime,
251 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200252 Server: server,
253 SDK: sdk,
Akronc376dcc2025-06-04 17:00:18 +0200254 MapID: mapID,
Akron40aaa632025-06-03 17:57:52 +0200255 MappingIDs: mappingIDs,
256 }
257
258 // Generate HTML
259 html := generateKalamarPluginHTML(data)
260
261 c.Set("Content-Type", "text/html")
262 return c.SendString(html)
263 }
264}
265
266// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
267// This function can be easily modified to change the appearance and content
268func generateKalamarPluginHTML(data TemplateData) string {
269 html := `<!DOCTYPE html>
270<html lang="en">
271<head>
272 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200273 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200274 <script src="` + data.SDK + `"
275 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200276</head>
277<body>
278 <div class="container">
279 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200280 <p>` + data.Description + `</p>`
281
282 if data.MapID != "" {
283 html += `<p>Map ID: ` + data.MapID + `</p>`
284 }
285
286 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200287 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
288 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
289 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200290
Akronc471c0a2025-06-04 11:56:22 +0200291 <h2>Available API Endpoints</h2>
292 <dl>
Akron40aaa632025-06-03 17:57:52 +0200293
Akronc376dcc2025-06-04 17:00:18 +0200294 <dt><tt><strong>GET</strong> /:map</tt></dt>
295 <dd><small>Kalamar integration</small></dd>
296
297 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
298 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
299
Akronc471c0a2025-06-04 11:56:22 +0200300 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200301
302 <h2>Available Term Mappings</h2>
Akronc471c0a2025-06-04 11:56:22 +0200303 <ul>`
Akron40aaa632025-06-03 17:57:52 +0200304
305 for _, id := range data.MappingIDs {
306 html += `
307 <li>` + id + `</li>`
308 }
309
310 html += `
Akronc376dcc2025-06-04 17:00:18 +0200311 </ul>`
Akron06d21f02025-06-04 14:36:07 +0200312
Akronc376dcc2025-06-04 17:00:18 +0200313 if data.MapID != "" {
314 html += ` <script>
Akron06d21f02025-06-04 14:36:07 +0200315 <!-- activates/deactivates Mapper. -->
316
317 let data = {
318 'action' : 'pipe',
Akronc376dcc2025-06-04 17:00:18 +0200319 'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/` + data.MapID + `/query'
Akron06d21f02025-06-04 14:36:07 +0200320 };
321
322 function pluginit (p) {
323 p.onMessage = function(msg) {
324 if (msg.key == 'termmapper') {
325 if (msg.value) {
326 data['job'] = 'add';
327 }
328 else {
329 data['job'] = 'del';
330 };
331 KorAPlugin.sendMsg(data);
332 };
333 };
334 };
Akronc376dcc2025-06-04 17:00:18 +0200335 </script>`
336 }
337
338 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200339</html>`
340
341 return html
342}