blob: 60dc0a04ae89aa6b01ec4921bdc9c9bafef4956c [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
Akron40aaa632025-06-03 17:57:52 +020038 MappingIDs []string
39}
40
Akrona00d4752025-05-26 17:34:36 +020041func parseConfig() *appConfig {
42 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020043
44 desc := config.Description
45 desc += " [" + config.Version + "]"
46
Akron1fc750e2025-05-26 16:54:18 +020047 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020048 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020049 kong.UsageOnError(),
50 )
51 if ctx.Error != nil {
52 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020053 os.Exit(1)
54 }
Akron49ceeb42025-05-23 17:46:01 +020055 return cfg
56}
57
58func setupLogger(level string) {
59 // Parse log level
60 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
61 if err != nil {
62 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
63 lvl = zerolog.InfoLevel
64 }
65
66 // Configure zerolog
67 zerolog.SetGlobalLevel(lvl)
68 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
69}
70
71func main() {
72 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020073 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020074
75 // Set up logging
Akron1fc750e2025-05-26 16:54:18 +020076 setupLogger(cfg.LogLevel)
Akron49ceeb42025-05-23 17:46:01 +020077
Akrona00d4752025-05-26 17:34:36 +020078 // Load configuration file
79 yamlConfig, err := config.LoadConfig(cfg.Config)
80 if err != nil {
81 log.Fatal().Err(err).Msg("Failed to load configuration")
82 }
83
Akron49ceeb42025-05-23 17:46:01 +020084 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +020085 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +020086 if err != nil {
87 log.Fatal().Err(err).Msg("Failed to create mapper")
88 }
89
90 // Create fiber app
91 app := fiber.New(fiber.Config{
92 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +020093 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +020094 })
95
96 // Set up routes
Akron40aaa632025-06-03 17:57:52 +020097 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +020098
99 // Start server
100 go func() {
Akron1fc750e2025-05-26 16:54:18 +0200101 log.Info().Int("port", cfg.Port).Msg("Starting server")
102 if err := app.Listen(fmt.Sprintf(":%d", cfg.Port)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200103 log.Fatal().Err(err).Msg("Server error")
104 }
105 }()
106
107 // Wait for interrupt signal
108 sigChan := make(chan os.Signal, 1)
109 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
110 <-sigChan
111
112 // Graceful shutdown
113 log.Info().Msg("Shutting down server")
114 if err := app.Shutdown(); err != nil {
115 log.Error().Err(err).Msg("Error during shutdown")
116 }
117}
118
Akron06d21f02025-06-04 14:36:07 +0200119func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200120 // Health check endpoint
121 app.Get("/health", func(c *fiber.Ctx) error {
122 return c.SendString("OK")
123 })
124
125 // Transformation endpoint
126 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200127
128 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200129 app.Get("/", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200130}
131
132func handleTransform(m *mapper.Mapper) fiber.Handler {
133 return func(c *fiber.Ctx) error {
134 // Get parameters
135 mapID := c.Params("map")
136 dir := c.Query("dir", "atob")
137 foundryA := c.Query("foundryA", "")
138 foundryB := c.Query("foundryB", "")
139 layerA := c.Query("layerA", "")
140 layerB := c.Query("layerB", "")
141
Akron74e1c072025-05-26 14:38:25 +0200142 // Validate input parameters
143 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
144 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
145 "error": err.Error(),
146 })
147 }
148
Akron49ceeb42025-05-23 17:46:01 +0200149 // Validate direction
150 if dir != "atob" && dir != "btoa" {
151 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
152 "error": "invalid direction, must be 'atob' or 'btoa'",
153 })
154 }
155
156 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200157 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200158 if err := c.BodyParser(&jsonData); err != nil {
159 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
160 "error": "invalid JSON in request body",
161 })
162 }
163
Akrona1a183f2025-05-26 17:47:33 +0200164 // Parse direction
165 direction, err := mapper.ParseDirection(dir)
166 if err != nil {
167 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
168 "error": err.Error(),
169 })
170 }
171
Akron49ceeb42025-05-23 17:46:01 +0200172 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200173 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200174 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200175 FoundryA: foundryA,
176 FoundryB: foundryB,
177 LayerA: layerA,
178 LayerB: layerB,
179 }, jsonData)
180
181 if err != nil {
182 log.Error().Err(err).
183 Str("mapID", mapID).
184 Str("direction", dir).
185 Msg("Failed to apply mappings")
186
187 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
188 "error": err.Error(),
189 })
190 }
191
192 return c.JSON(result)
193 }
194}
Akron74e1c072025-05-26 14:38:25 +0200195
196// validateInput checks if the input parameters are valid
197func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200198 // Define parameter checks
199 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200200 name string
201 value string
202 }{
203 {"mapID", mapID},
204 {"dir", dir},
205 {"foundryA", foundryA},
206 {"foundryB", foundryB},
207 {"layerA", layerA},
208 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200209 }
210
211 for _, param := range params {
212 // Check input lengths
213 if len(param.value) > maxParamLength {
214 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
215 }
216 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200217 if strings.ContainsAny(param.value, "<>{}[]\\") {
218 return fmt.Errorf("%s contains invalid characters", param.name)
219 }
220 }
221
Akron69d43bf2025-05-26 17:09:00 +0200222 if len(body) > maxInputLength {
223 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
224 }
225
Akron74e1c072025-05-26 14:38:25 +0200226 return nil
227}
Akron40aaa632025-06-03 17:57:52 +0200228
Akron06d21f02025-06-04 14:36:07 +0200229func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200230 return func(c *fiber.Ctx) error {
231 // Get list of available mapping IDs
232 var mappingIDs []string
233 for _, list := range yamlConfig.Lists {
234 mappingIDs = append(mappingIDs, list.ID)
235 }
236
Akron06d21f02025-06-04 14:36:07 +0200237 // Use values from config (defaults are already applied during parsing)
238 server := yamlConfig.Server
239 sdk := yamlConfig.SDK
240
Akron40aaa632025-06-03 17:57:52 +0200241 // Prepare template data
242 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200243 Title: config.Title,
244 Version: config.Version,
245 Hash: config.Buildhash,
246 Date: config.Buildtime,
247 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200248 Server: server,
249 SDK: sdk,
Akron40aaa632025-06-03 17:57:52 +0200250 MappingIDs: mappingIDs,
251 }
252
253 // Generate HTML
254 html := generateKalamarPluginHTML(data)
255
256 c.Set("Content-Type", "text/html")
257 return c.SendString(html)
258 }
259}
260
261// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
262// This function can be easily modified to change the appearance and content
263func generateKalamarPluginHTML(data TemplateData) string {
264 html := `<!DOCTYPE html>
265<html lang="en">
266<head>
267 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200268 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200269 <script src="` + data.SDK + `"
270 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200271</head>
272<body>
273 <div class="container">
274 <h1>` + data.Title + `</h1>
Akronc471c0a2025-06-04 11:56:22 +0200275 <p>` + data.Description + `</p>
Akron40aaa632025-06-03 17:57:52 +0200276
Akronc471c0a2025-06-04 11:56:22 +0200277 <h2>Plugin Information</h2>
278 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
279 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
280 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200281
Akronc471c0a2025-06-04 11:56:22 +0200282 <h2>Available API Endpoints</h2>
283 <dl>
284 <dt><tt><strong>POST</strong> /:map/query?dir=atob&foundryA=&foundryB=&layerA=&layerB=</tt></dt>
285 <dd><small>Transform JSON objects using term mapping rules</small></dd>
286
287 <dt><tt><strong>GET</strong> /health</tt></dt>
288 <dd><small>Health check endpoint</small></dd>
Akron40aaa632025-06-03 17:57:52 +0200289
Akronc471c0a2025-06-04 11:56:22 +0200290 <dt><tt><strong>GET</strong> /</tt></dt>
291 <dd><small>This entry point for Kalamar integration</small></dd>
292 </dl>
293
294 <h2>Available Term Mappings</h2>
295 <ul>`
Akron40aaa632025-06-03 17:57:52 +0200296
297 for _, id := range data.MappingIDs {
298 html += `
299 <li>` + id + `</li>`
300 }
301
302 html += `
Akronc471c0a2025-06-04 11:56:22 +0200303 </ul>
Akron06d21f02025-06-04 14:36:07 +0200304
305 <script>
306 <!-- activates/deactivates Mapper. -->
307
308 let data = {
309 'action' : 'pipe',
310 'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/query'
311 };
312
313 function pluginit (p) {
314 p.onMessage = function(msg) {
315 if (msg.key == 'termmapper') {
316 if (msg.value) {
317 data['job'] = 'add';
318 }
319 else {
320 data['job'] = 'del';
321 };
322 KorAPlugin.sendMsg(data);
323 };
324 };
325 };
326 </script>
Akronc471c0a2025-06-04 11:56:22 +0200327 </body>
Akron40aaa632025-06-03 17:57:52 +0200328</html>`
329
330 return html
331}