github.com/PDOK/gokoala@v0.50.6/internal/engine/engine.go (about) 1 package engine 2 3 import ( 4 "bytes" 5 "compress/gzip" 6 "context" 7 "errors" 8 "fmt" 9 htmltemplate "html/template" 10 "io" 11 "log" 12 "net/http" 13 "net/http/httputil" 14 "net/url" 15 "os" 16 "os/signal" 17 "syscall" 18 texttemplate "text/template" 19 "time" 20 21 "github.com/PDOK/gokoala/config" 22 23 "github.com/go-chi/chi/v5" 24 "github.com/go-chi/chi/v5/middleware" 25 ) 26 27 const ( 28 templatesDir = "internal/engine/templates/" 29 shutdownTimeout = 5 * time.Second 30 31 HeaderLink = "Link" 32 HeaderAccept = "Accept" 33 HeaderAcceptLanguage = "Accept-Language" 34 HeaderAcceptRanges = "Accept-Ranges" 35 HeaderRange = "Range" 36 HeaderContentType = "Content-Type" 37 HeaderContentLength = "Content-Length" 38 HeaderContentCrs = "Content-Crs" 39 HeaderContentEncoding = "Content-Encoding" 40 HeaderBaseURL = "X-BaseUrl" 41 HeaderRequestedWith = "X-Requested-With" 42 HeaderAPIVersion = "API-Version" 43 ) 44 45 // Engine encapsulates shared non-OGC API specific logic 46 type Engine struct { 47 Config *config.Config 48 OpenAPI *OpenAPI 49 Templates *Templates 50 CN *ContentNegotiation 51 Router *chi.Mux 52 53 shutdownHooks []func() 54 } 55 56 // NewEngine builds a new Engine 57 func NewEngine(configFile string, openAPIFile string, enableTrailingSlash bool, enableCORS bool) (*Engine, error) { 58 cfg, err := config.NewConfig(configFile) 59 if err != nil { 60 return nil, err 61 } 62 return NewEngineWithConfig(cfg, openAPIFile, enableTrailingSlash, enableCORS), nil 63 } 64 65 // NewEngineWithConfig builds a new Engine 66 func NewEngineWithConfig(config *config.Config, openAPIFile string, enableTrailingSlash bool, enableCORS bool) *Engine { 67 contentNegotiation := newContentNegotiation(config.AvailableLanguages) 68 templates := newTemplates(config) 69 openAPI := newOpenAPI(config, []string{openAPIFile}, nil) 70 router := newRouter(config.Version, enableTrailingSlash, enableCORS) 71 72 engine := &Engine{ 73 Config: config, 74 OpenAPI: openAPI, 75 Templates: templates, 76 CN: contentNegotiation, 77 Router: router, 78 } 79 80 if config.Resources != nil { 81 newResourcesEndpoint(engine) // Resources endpoint to serve static assets 82 } 83 router.Get("/health", func(w http.ResponseWriter, _ *http.Request) { 84 SafeWrite(w.Write, []byte("OK")) // Health endpoint 85 }) 86 return engine 87 } 88 89 // Start the engine by initializing all components and starting the server 90 func (e *Engine) Start(address string, debugPort int, shutdownDelay int) error { 91 // debug server (binds to localhost). 92 if debugPort > 0 { 93 go func() { 94 debugAddress := fmt.Sprintf("localhost:%d", debugPort) 95 debugRouter := chi.NewRouter() 96 debugRouter.Use(middleware.Logger) 97 debugRouter.Mount("/debug", middleware.Profiler()) 98 err := e.startServer("debug server", debugAddress, 0, debugRouter) 99 if err != nil { 100 log.Fatalf("debug server failed %v", err) 101 } 102 }() 103 } 104 105 // main server 106 return e.startServer("main server", address, shutdownDelay, e.Router) 107 } 108 109 // startServer creates and starts an HTTP server, also takes care of graceful shutdown 110 func (e *Engine) startServer(name string, address string, shutdownDelay int, router *chi.Mux) error { 111 // create HTTP server 112 server := http.Server{ 113 Addr: address, 114 Handler: router, 115 116 ReadTimeout: 15 * time.Second, 117 ReadHeaderTimeout: 15 * time.Second, 118 } 119 120 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) 121 defer stop() 122 123 go func() { 124 log.Printf("%s listening on http://%2s", name, address) 125 // ListenAndServe always returns a non-nil error. After Shutdown or 126 // Close, the returned error is ErrServerClosed 127 if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 128 log.Fatalf("failed to shutdown %s: %v", name, err) 129 } 130 }() 131 132 // listen for interrupt signal and then perform shutdown 133 <-ctx.Done() 134 stop() 135 136 // execute shutdown hooks 137 for _, shutdownHook := range e.shutdownHooks { 138 shutdownHook() 139 } 140 141 if shutdownDelay > 0 { 142 log.Printf("stop signal received, initiating shutdown of %s after %d seconds delay", name, shutdownDelay) 143 time.Sleep(time.Duration(shutdownDelay) * time.Second) 144 } 145 log.Printf("shutting down %s gracefully", name) 146 147 // shutdown with a max timeout. 148 timeoutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) 149 defer cancel() 150 return server.Shutdown(timeoutCtx) 151 } 152 153 // RegisterShutdownHook register a func to execute during graceful shutdown, e.g. to clean up resources. 154 func (e *Engine) RegisterShutdownHook(fn func()) { 155 e.shutdownHooks = append(e.shutdownHooks, fn) 156 } 157 158 // RebuildOpenAPI rebuild the full OpenAPI spec with the newly given parameters. 159 // Use only once during bootstrap for specific use cases! For example: when you want to expand a 160 // specific part of the OpenAPI spec with data outside the configuration file (e.g. from a database). 161 func (e *Engine) RebuildOpenAPI(openAPIParams any) { 162 e.OpenAPI = newOpenAPI(e.Config, e.OpenAPI.extraOpenAPIFiles, openAPIParams) 163 } 164 165 // ParseTemplate parses both HTML and non-HTML templates depending on the format given in the TemplateKey and 166 // stores it in the engine for future rendering using RenderAndServePage. 167 func (e *Engine) ParseTemplate(key TemplateKey) { 168 e.Templates.parseAndSaveTemplate(key) 169 } 170 171 // RenderTemplates renders both HTML and non-HTML templates depending on the format given in the TemplateKey. 172 // This method also performs OpenAPI validation of the rendered template, therefore we also need the URL path. 173 // The rendered templates are stored in the engine for future serving using ServePage. 174 func (e *Engine) RenderTemplates(urlPath string, breadcrumbs []Breadcrumb, keys ...TemplateKey) { 175 for _, key := range keys { 176 e.Templates.renderAndSaveTemplate(key, breadcrumbs, nil) 177 178 // we already perform OpenAPI validation here during startup to catch 179 // issues early on, in addition to runtime OpenAPI response validation 180 // all templates are created in all available languages, hence all are checked 181 for lang := range e.Templates.localizers { 182 key.Language = lang 183 if err := e.validateStaticResponse(key, urlPath); err != nil { 184 log.Fatal(err) 185 } 186 } 187 } 188 } 189 190 // RenderTemplatesWithParams renders both HTMl and non-HTML templates depending on the format given in the TemplateKey. 191 // This method does not perform OpenAPI validation of the rendered template (will be done during runtime). 192 func (e *Engine) RenderTemplatesWithParams(params any, breadcrumbs []Breadcrumb, keys ...TemplateKey) { 193 for _, key := range keys { 194 e.Templates.renderAndSaveTemplate(key, breadcrumbs, params) 195 } 196 } 197 198 // RenderAndServePage renders an already parsed HTML or non-HTML template and renders it on-the-fly depending 199 // on the format in the given TemplateKey. The result isn't store in engine, it's served directly to the client. 200 // 201 // NOTE: only used this for dynamic pages that can't be pre-rendered and cached (e.g. with data from a backing store). 202 func (e *Engine) RenderAndServePage(w http.ResponseWriter, r *http.Request, key TemplateKey, 203 params any, breadcrumbs []Breadcrumb) { 204 205 // validate request 206 if err := e.OpenAPI.ValidateRequest(r); err != nil { 207 log.Printf("%v", err.Error()) 208 RenderProblem(ProblemBadRequest, w, err.Error()) 209 return 210 } 211 212 // get template 213 parsedTemplate, err := e.Templates.getParsedTemplate(key) 214 if err != nil { 215 log.Printf("%v", err.Error()) 216 RenderProblem(ProblemServerError, w) 217 } 218 219 // render output 220 var output []byte 221 if key.Format == FormatHTML { 222 htmlTmpl := parsedTemplate.(*htmltemplate.Template) 223 output = e.Templates.renderHTMLTemplate(htmlTmpl, r.URL, params, breadcrumbs, "") 224 } else { 225 jsonTmpl := parsedTemplate.(*texttemplate.Template) 226 output = e.Templates.renderNonHTMLTemplate(jsonTmpl, params, key, "") 227 } 228 contentType := e.CN.formatToMediaType(key.Format) 229 230 // validate response 231 if err := e.OpenAPI.ValidateResponse(contentType, output, r); err != nil { 232 log.Printf("%v", err.Error()) 233 RenderProblem(ProblemServerError, w, err.Error()) 234 return 235 } 236 237 // return response output to client 238 if contentType != "" { 239 w.Header().Set(HeaderContentType, contentType) 240 } 241 SafeWrite(w.Write, output) 242 } 243 244 // ServePage serves a pre-rendered template while also validating against the OpenAPI spec 245 func (e *Engine) ServePage(w http.ResponseWriter, r *http.Request, templateKey TemplateKey) { 246 // validate request 247 if err := e.OpenAPI.ValidateRequest(r); err != nil { 248 log.Printf("%v", err.Error()) 249 RenderProblem(ProblemBadRequest, w, err.Error()) 250 return 251 } 252 253 // render output 254 output, err := e.Templates.getRenderedTemplate(templateKey) 255 if err != nil { 256 log.Printf("%v", err.Error()) 257 RenderProblem(ProblemNotFound, w) 258 return 259 } 260 contentType := e.CN.formatToMediaType(templateKey.Format) 261 262 // validate response 263 if err := e.OpenAPI.ValidateResponse(contentType, output, r); err != nil { 264 log.Printf("%v", err.Error()) 265 RenderProblem(ProblemServerError, w, err.Error()) 266 return 267 } 268 269 // return response output to client 270 if contentType != "" { 271 w.Header().Set(HeaderContentType, contentType) 272 } 273 SafeWrite(w.Write, output) 274 } 275 276 // ServeResponse serves the given response (arbitrary bytes) while also validating against the OpenAPI spec 277 func (e *Engine) ServeResponse(w http.ResponseWriter, r *http.Request, 278 validateRequest bool, validateResponse bool, contentType string, response []byte) { 279 280 if validateRequest { 281 if err := e.OpenAPI.ValidateRequest(r); err != nil { 282 log.Printf("%v", err.Error()) 283 RenderProblem(ProblemBadRequest, w, err.Error()) 284 return 285 } 286 } 287 288 if validateResponse { 289 if err := e.OpenAPI.ValidateResponse(contentType, response, r); err != nil { 290 log.Printf("%v", err.Error()) 291 RenderProblem(ProblemServerError, w, err.Error()) 292 return 293 } 294 } 295 296 // return response output to client 297 if contentType != "" { 298 w.Header().Set(HeaderContentType, contentType) 299 } 300 SafeWrite(w.Write, response) 301 } 302 303 // ReverseProxy forwards given HTTP request to given target server, and optionally tweaks response 304 func (e *Engine) ReverseProxy(w http.ResponseWriter, r *http.Request, target *url.URL, 305 prefer204 bool, contentTypeOverwrite string) { 306 e.ReverseProxyAndValidate(w, r, target, prefer204, contentTypeOverwrite, false) 307 } 308 309 // ReverseProxyAndValidate forwards given HTTP request to given target server, and optionally tweaks and validates response 310 func (e *Engine) ReverseProxyAndValidate(w http.ResponseWriter, r *http.Request, target *url.URL, 311 prefer204 bool, contentTypeOverwrite string, validateResponse bool) { 312 313 rewrite := func(r *httputil.ProxyRequest) { 314 r.Out.URL = target 315 r.Out.Host = "" // Don't pass Host header (similar to Traefik's passHostHeader=false) 316 r.SetXForwarded() // Set X-Forwarded-* headers. 317 r.Out.Header.Set(HeaderBaseURL, e.Config.BaseURL.String()) 318 } 319 320 errorHandler := func(w http.ResponseWriter, _ *http.Request, err error) { 321 log.Printf("failed to proxy request: %v", err) 322 RenderProblem(ProblemBadGateway, w) 323 } 324 325 modifyResponse := func(proxyRes *http.Response) error { 326 if prefer204 { 327 // OGC spec: If the tile has no content due to lack of data in the area, but is within the data 328 // resource its tile matrix sets and tile matrix sets limits, the HTTP response will use the status 329 // code either 204 (indicating an empty tile with no content) or a 200 330 if proxyRes.StatusCode == http.StatusNotFound { 331 proxyRes.StatusCode = http.StatusNoContent 332 removeBody(proxyRes) 333 } 334 } 335 if contentTypeOverwrite != "" { 336 proxyRes.Header.Set(HeaderContentType, contentTypeOverwrite) 337 } 338 if contentType := proxyRes.Header.Get(HeaderContentType); contentType == MediaTypeJSON && validateResponse { 339 var reader io.ReadCloser 340 var err error 341 if proxyRes.Header.Get(HeaderContentEncoding) == FormatGzip { 342 reader, err = gzip.NewReader(proxyRes.Body) 343 if err != nil { 344 return err 345 } 346 } else { 347 reader = proxyRes.Body 348 } 349 res, err := io.ReadAll(reader) 350 if err != nil { 351 return err 352 } 353 e.ServeResponse(w, r, false, true, contentType, res) 354 } 355 return nil 356 } 357 358 reverseProxy := &httputil.ReverseProxy{ 359 Rewrite: rewrite, 360 ModifyResponse: modifyResponse, 361 ErrorHandler: errorHandler, 362 } 363 reverseProxy.ServeHTTP(w, r) 364 } 365 366 func removeBody(proxyRes *http.Response) { 367 buf := bytes.NewBuffer(make([]byte, 0)) 368 proxyRes.Body = io.NopCloser(buf) 369 proxyRes.Header[HeaderContentLength] = []string{"0"} 370 proxyRes.Header[HeaderContentType] = []string{} 371 } 372 373 func (e *Engine) validateStaticResponse(key TemplateKey, urlPath string) error { 374 template, _ := e.Templates.getRenderedTemplate(key) 375 serverURL := normalizeBaseURL(e.Config.BaseURL.String()) 376 req, err := http.NewRequest(http.MethodGet, serverURL+urlPath, nil) 377 if err != nil { 378 return fmt.Errorf("failed to construct request to validate %s "+ 379 "template against OpenAPI spec %v", key.Name, err) 380 } 381 err = e.OpenAPI.ValidateResponse(e.CN.formatToMediaType(key.Format), template, req) 382 if err != nil { 383 return fmt.Errorf("validation of template %s failed: %w", key.Name, err) 384 } 385 return nil 386 } 387 388 // SafeWrite executes the given http.ResponseWriter.Write while logging errors 389 func SafeWrite(write func([]byte) (int, error), body []byte) { 390 _, err := write(body) 391 if err != nil { 392 log.Printf("failed to write response: %v", err) 393 } 394 }