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  }