blob: 4ba3dff44d204daea9816b8e659772db545a0308 [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
33 Description string
34 MappingIDs []string
35}
36
Akrona00d4752025-05-26 17:34:36 +020037func parseConfig() *appConfig {
38 cfg := &appConfig{}
Akron1fc750e2025-05-26 16:54:18 +020039 ctx := kong.Parse(cfg,
40 kong.Description("A web service for transforming JSON objects using term mapping rules."),
41 kong.UsageOnError(),
42 )
43 if ctx.Error != nil {
44 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020045 os.Exit(1)
46 }
Akron49ceeb42025-05-23 17:46:01 +020047 return cfg
48}
49
50func setupLogger(level string) {
51 // Parse log level
52 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
53 if err != nil {
54 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
55 lvl = zerolog.InfoLevel
56 }
57
58 // Configure zerolog
59 zerolog.SetGlobalLevel(lvl)
60 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
61}
62
63func main() {
64 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020065 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020066
67 // Set up logging
Akron1fc750e2025-05-26 16:54:18 +020068 setupLogger(cfg.LogLevel)
Akron49ceeb42025-05-23 17:46:01 +020069
Akrona00d4752025-05-26 17:34:36 +020070 // Load configuration file
71 yamlConfig, err := config.LoadConfig(cfg.Config)
72 if err != nil {
73 log.Fatal().Err(err).Msg("Failed to load configuration")
74 }
75
Akron49ceeb42025-05-23 17:46:01 +020076 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +020077 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +020078 if err != nil {
79 log.Fatal().Err(err).Msg("Failed to create mapper")
80 }
81
82 // Create fiber app
83 app := fiber.New(fiber.Config{
84 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +020085 BodyLimit: maxInputLength,
Akron49ceeb42025-05-23 17:46:01 +020086 })
87
88 // Set up routes
Akron40aaa632025-06-03 17:57:52 +020089 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +020090
91 // Start server
92 go func() {
Akron1fc750e2025-05-26 16:54:18 +020093 log.Info().Int("port", cfg.Port).Msg("Starting server")
94 if err := app.Listen(fmt.Sprintf(":%d", cfg.Port)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +020095 log.Fatal().Err(err).Msg("Server error")
96 }
97 }()
98
99 // Wait for interrupt signal
100 sigChan := make(chan os.Signal, 1)
101 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
102 <-sigChan
103
104 // Graceful shutdown
105 log.Info().Msg("Shutting down server")
106 if err := app.Shutdown(); err != nil {
107 log.Error().Err(err).Msg("Error during shutdown")
108 }
109}
110
Akron40aaa632025-06-03 17:57:52 +0200111func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingLists) {
Akron49ceeb42025-05-23 17:46:01 +0200112 // Health check endpoint
113 app.Get("/health", func(c *fiber.Ctx) error {
114 return c.SendString("OK")
115 })
116
117 // Transformation endpoint
118 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200119
120 // Kalamar plugin endpoint
121 app.Get("/kalamarplugin", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200122}
123
124func handleTransform(m *mapper.Mapper) fiber.Handler {
125 return func(c *fiber.Ctx) error {
126 // Get parameters
127 mapID := c.Params("map")
128 dir := c.Query("dir", "atob")
129 foundryA := c.Query("foundryA", "")
130 foundryB := c.Query("foundryB", "")
131 layerA := c.Query("layerA", "")
132 layerB := c.Query("layerB", "")
133
Akron74e1c072025-05-26 14:38:25 +0200134 // Validate input parameters
135 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
136 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
137 "error": err.Error(),
138 })
139 }
140
Akron49ceeb42025-05-23 17:46:01 +0200141 // Validate direction
142 if dir != "atob" && dir != "btoa" {
143 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
144 "error": "invalid direction, must be 'atob' or 'btoa'",
145 })
146 }
147
148 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200149 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200150 if err := c.BodyParser(&jsonData); err != nil {
151 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
152 "error": "invalid JSON in request body",
153 })
154 }
155
Akrona1a183f2025-05-26 17:47:33 +0200156 // Parse direction
157 direction, err := mapper.ParseDirection(dir)
158 if err != nil {
159 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
160 "error": err.Error(),
161 })
162 }
163
Akron49ceeb42025-05-23 17:46:01 +0200164 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200165 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200166 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200167 FoundryA: foundryA,
168 FoundryB: foundryB,
169 LayerA: layerA,
170 LayerB: layerB,
171 }, jsonData)
172
173 if err != nil {
174 log.Error().Err(err).
175 Str("mapID", mapID).
176 Str("direction", dir).
177 Msg("Failed to apply mappings")
178
179 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
180 "error": err.Error(),
181 })
182 }
183
184 return c.JSON(result)
185 }
186}
Akron74e1c072025-05-26 14:38:25 +0200187
188// validateInput checks if the input parameters are valid
189func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200190 // Define parameter checks
191 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200192 name string
193 value string
194 }{
195 {"mapID", mapID},
196 {"dir", dir},
197 {"foundryA", foundryA},
198 {"foundryB", foundryB},
199 {"layerA", layerA},
200 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200201 }
202
203 for _, param := range params {
204 // Check input lengths
205 if len(param.value) > maxParamLength {
206 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
207 }
208 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200209 if strings.ContainsAny(param.value, "<>{}[]\\") {
210 return fmt.Errorf("%s contains invalid characters", param.name)
211 }
212 }
213
Akron69d43bf2025-05-26 17:09:00 +0200214 if len(body) > maxInputLength {
215 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
216 }
217
Akron74e1c072025-05-26 14:38:25 +0200218 return nil
219}
Akron40aaa632025-06-03 17:57:52 +0200220
221func handleKalamarPlugin(yamlConfig *config.MappingLists) fiber.Handler {
222 return func(c *fiber.Ctx) error {
223 // Get list of available mapping IDs
224 var mappingIDs []string
225 for _, list := range yamlConfig.Lists {
226 mappingIDs = append(mappingIDs, list.ID)
227 }
228
229 // Prepare template data
230 data := TemplateData{
231 Title: "KoralPipe TermMapper - Kalamar Plugin",
232 Version: "1.0.0",
233 Description: "A KortalPipe web service for transforming JSON objects using term mapping rules.",
234 MappingIDs: mappingIDs,
235 }
236
237 // Generate HTML
238 html := generateKalamarPluginHTML(data)
239
240 c.Set("Content-Type", "text/html")
241 return c.SendString(html)
242 }
243}
244
245// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
246// This function can be easily modified to change the appearance and content
247func generateKalamarPluginHTML(data TemplateData) string {
248 html := `<!DOCTYPE html>
249<html lang="en">
250<head>
251 <meta charset="UTF-8">
252 <meta name="viewport" content="width=device-width, initial-scale=1.0">
253 <title>` + data.Title + `</title>
254</head>
255<body>
256 <div class="container">
257 <h1>` + data.Title + `</h1>
258
259 <div>
260 <h2>Plugin Information</h2>
261 <p><strong>Version:</strong> ` + data.Version + `</p>
262 <p><strong>Description:</strong> ` + data.Description + `</p>
263 </div>
264
265 <div class="plugin-info">
266 <h2>Available API Endpoints</h2>
267 <div class="api-endpoint">
268 <strong>POST</strong> /:map/query?dir=atob&foundryA=&foundryB=&layerA=&layerB=
269 <br><small>Transform JSON objects using term mapping rules</small>
270 </div>
271 <div class="api-endpoint">
272 <strong>GET</strong> /health
273 <br><small>Health check endpoint</small>
274 </div>
275 <div class="api-endpoint">
276 <strong>GET</strong> /kalamarplugin
277 <br><small>This entry point for Kalamar integration</small>
278 </div>
279 </div>
280
281 <div class="plugin-info">
282 <h2>Available Term Mappings</h2>
283 <ul>`
284
285 for _, id := range data.MappingIDs {
286 html += `
287 <li>` + id + `</li>`
288 }
289
290 html += `
291 </ul>
292 </div>
293</body>
294</html>`
295
296 return html
297}