github.com/PDOK/gokoala@v0.50.6/internal/engine/openapi.go (about)

     1  package engine
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"log"
    11  	"net/http"
    12  	"net/url"
    13  	"path/filepath"
    14  	"regexp"
    15  	"strings"
    16  	texttemplate "text/template"
    17  
    18  	gokoalaconfig "github.com/PDOK/gokoala/config"
    19  	orderedmap "github.com/wk8/go-ordered-map/v2"
    20  
    21  	"github.com/PDOK/gokoala/internal/engine/util"
    22  	"github.com/getkin/kin-openapi/openapi3"
    23  	"github.com/getkin/kin-openapi/openapi3filter"
    24  	"github.com/getkin/kin-openapi/routers"
    25  	"github.com/getkin/kin-openapi/routers/gorillamux"
    26  )
    27  
    28  const (
    29  	specPath          = templatesDir + "openapi/"
    30  	preamble          = specPath + "preamble.go.json"
    31  	problems          = specPath + "problems.go.json"
    32  	commonCollections = specPath + "common-collections.go.json"
    33  	featuresSpec      = specPath + "features.go.json"
    34  	tilesSpec         = specPath + "tiles.go.json"
    35  	stylesSpec        = specPath + "styles.go.json"
    36  	geoVolumesSpec    = specPath + "3dgeovolumes.go.json"
    37  	commonSpec        = specPath + "common.go.json"
    38  	HTMLRegex         = `<[/]?([a-zA-Z]+).*?>`
    39  )
    40  
    41  type OpenAPI struct {
    42  	spec     *openapi3.T
    43  	SpecJSON []byte
    44  
    45  	config            *gokoalaconfig.Config
    46  	router            routers.Router
    47  	extraOpenAPIFiles []string
    48  }
    49  
    50  func newOpenAPI(config *gokoalaconfig.Config, extraOpenAPIFiles []string, openAPIParams any) *OpenAPI {
    51  	setupRequestResponseValidation()
    52  	ctx := context.Background()
    53  
    54  	// order matters, see mergeSpecs for details.
    55  	defaultOpenAPIFiles := []string{commonSpec}
    56  	if config.AllCollections() != nil {
    57  		defaultOpenAPIFiles = append(defaultOpenAPIFiles, commonCollections)
    58  	}
    59  	if config.OgcAPI.Tiles != nil {
    60  		defaultOpenAPIFiles = append(defaultOpenAPIFiles, tilesSpec)
    61  	}
    62  	if config.OgcAPI.Features != nil {
    63  		defaultOpenAPIFiles = append(defaultOpenAPIFiles, featuresSpec)
    64  	}
    65  	if config.OgcAPI.Styles != nil {
    66  		defaultOpenAPIFiles = append(defaultOpenAPIFiles, stylesSpec)
    67  	}
    68  	if config.OgcAPI.GeoVolumes != nil {
    69  		defaultOpenAPIFiles = append(defaultOpenAPIFiles, geoVolumesSpec)
    70  	}
    71  
    72  	// add preamble first
    73  	openAPIFiles := []string{preamble}
    74  	// add extra spec(s) thereafter, to allow it to override default openapi specs
    75  	openAPIFiles = append(openAPIFiles, extraOpenAPIFiles...)
    76  	openAPIFiles = append(openAPIFiles, defaultOpenAPIFiles...)
    77  
    78  	resultSpec, resultSpecJSON := mergeSpecs(ctx, config, openAPIFiles, openAPIParams)
    79  	validateSpec(ctx, resultSpec, resultSpecJSON)
    80  
    81  	for _, server := range resultSpec.Servers {
    82  		server.URL = normalizeBaseURL(server.URL)
    83  	}
    84  
    85  	return &OpenAPI{
    86  		config:            config,
    87  		spec:              resultSpec,
    88  		SpecJSON:          util.PrettyPrintJSON(resultSpecJSON, ""),
    89  		router:            newOpenAPIRouter(resultSpec),
    90  		extraOpenAPIFiles: extraOpenAPIFiles,
    91  	}
    92  }
    93  
    94  func setupRequestResponseValidation() {
    95  	htmlRegex := regexp.MustCompile(HTMLRegex)
    96  
    97  	openapi3filter.RegisterBodyDecoder(MediaTypeHTML,
    98  		func(body io.Reader, _ http.Header, _ *openapi3.SchemaRef,
    99  			_ openapi3filter.EncodingFn) (any, error) {
   100  
   101  			data, err := io.ReadAll(body)
   102  			if err != nil {
   103  				return nil, errors.New("failed to read response body")
   104  			}
   105  			if !htmlRegex.Match(data) {
   106  				return nil, errors.New("response doesn't contain HTML")
   107  			}
   108  			return string(data), nil
   109  		})
   110  
   111  	for _, mediaType := range MediaTypeJSONFamily {
   112  		openapi3filter.RegisterBodyDecoder(mediaType,
   113  			func(body io.Reader, _ http.Header, _ *openapi3.SchemaRef,
   114  				_ openapi3filter.EncodingFn) (any, error) {
   115  				var value any
   116  				dec := json.NewDecoder(body)
   117  				dec.UseNumber()
   118  				if err := dec.Decode(&value); err != nil {
   119  					return nil, errors.New("response doesn't contain valid JSON")
   120  				}
   121  				return value, nil
   122  			})
   123  	}
   124  }
   125  
   126  // mergeSpecs merges the given OpenAPI specs.
   127  //
   128  // Order matters! We start with the preamble, it is highest in rank and there's no way to override it.
   129  // Then the files are merged according to their given order. Files that are merged first
   130  // have a higher change of getting their changes in the final spec than files that follow later.
   131  //
   132  // The OpenAPI spec optionally provided through the CLI should be the second (after preamble) item in the
   133  // `files` slice since it allows the user to override other/default specs.
   134  func mergeSpecs(ctx context.Context, config *gokoalaconfig.Config, files []string, params any) (*openapi3.T, []byte) {
   135  	loader := &openapi3.Loader{Context: ctx, IsExternalRefsAllowed: false}
   136  
   137  	if len(files) < 1 {
   138  		log.Fatalf("files can't be empty, at least OGC Common is expected")
   139  	}
   140  	var resultSpecJSON []byte
   141  	var resultSpec *openapi3.T
   142  
   143  	for _, file := range files {
   144  		if file == "" {
   145  			continue
   146  		}
   147  		specJSON := renderOpenAPITemplate(config, file, params)
   148  		var mergedJSON []byte
   149  		if resultSpecJSON == nil {
   150  			mergedJSON = specJSON
   151  		} else {
   152  			var err error
   153  			mergedJSON, err = util.MergeJSON(resultSpecJSON, specJSON, orderByOpenAPIConvention)
   154  			if err != nil {
   155  				log.Print(string(mergedJSON))
   156  				log.Fatalf("failed to merge OpenAPI specs: %v", err)
   157  			}
   158  		}
   159  		resultSpecJSON = mergedJSON
   160  		resultSpec = loadSpec(loader, mergedJSON)
   161  	}
   162  	return resultSpec, resultSpecJSON
   163  }
   164  
   165  func orderByOpenAPIConvention(output map[string]any) any {
   166  	result := orderedmap.New[string, any]()
   167  	// OpenAPI specs are commonly ordered according to the following sequence.
   168  	desiredOrder := []string{"openapi", "info", "servers", "paths", "components"}
   169  	for _, order := range desiredOrder {
   170  		for k, v := range output {
   171  			if k == order {
   172  				result.Set(k, v)
   173  			}
   174  		}
   175  	}
   176  	// add remaining keys
   177  	for k, v := range output {
   178  		result.Set(k, v)
   179  	}
   180  	return result
   181  }
   182  
   183  func loadSpec(loader *openapi3.Loader, mergedJSON []byte, fileName ...string) *openapi3.T {
   184  	resultSpec, err := loader.LoadFromData(mergedJSON)
   185  	if err != nil {
   186  		log.Print(string(mergedJSON))
   187  		log.Fatalf("failed to load merged OpenAPI spec %s, due to %v", fileName, err)
   188  	}
   189  	return resultSpec
   190  }
   191  
   192  func validateSpec(ctx context.Context, finalSpec *openapi3.T, finalSpecRaw []byte) {
   193  	// Validate OGC OpenAPI spec. Note: the examples provided in the official spec aren't valid.
   194  	err := finalSpec.Validate(ctx, openapi3.DisableExamplesValidation())
   195  	if err != nil {
   196  		log.Print(string(finalSpecRaw))
   197  		log.Fatalf("invalid OpenAPI spec: %v", err)
   198  	}
   199  }
   200  
   201  func newOpenAPIRouter(doc *openapi3.T) routers.Router {
   202  	openAPIRouter, err := gorillamux.NewRouter(doc)
   203  	if err != nil {
   204  		log.Fatalf("failed to setup OpenAPI router: %v", err)
   205  	}
   206  	return openAPIRouter
   207  }
   208  
   209  func renderOpenAPITemplate(config *gokoalaconfig.Config, fileName string, params any) []byte {
   210  	file := filepath.Clean(fileName)
   211  	files := []string{problems, file} // add problems template too since it's an "include" template
   212  	parsed := texttemplate.Must(texttemplate.New(filepath.Base(file)).Funcs(globalTemplateFuncs).ParseFiles(files...))
   213  
   214  	var rendered bytes.Buffer
   215  	if err := parsed.Execute(&rendered, &TemplateData{Config: config, Params: params}); err != nil {
   216  		log.Fatalf("failed to render %s, error: %v", file, err)
   217  	}
   218  	return rendered.Bytes()
   219  }
   220  
   221  func (o *OpenAPI) ValidateRequest(r *http.Request) error {
   222  	requestValidationInput, _ := o.getRequestValidationInput(r)
   223  	if requestValidationInput != nil {
   224  		err := openapi3filter.ValidateRequest(context.Background(), requestValidationInput)
   225  		if err != nil {
   226  			var schemaErr *openapi3.SchemaError
   227  			// Don't fail on maximum constraints because OGC has decided these are soft limits, for instance
   228  			// in features: "If the value of the limit parameter is larger than the maximum value, this
   229  			// SHALL NOT result in an error (instead use the maximum as the parameter value)."
   230  			if errors.As(err, &schemaErr) && schemaErr.SchemaField == "maximum" {
   231  				return nil
   232  			}
   233  			return fmt.Errorf("request doesn't conform to OpenAPI spec: %w", err)
   234  		}
   235  	}
   236  	return nil
   237  }
   238  
   239  func (o *OpenAPI) ValidateResponse(contentType string, body []byte, r *http.Request) error {
   240  	requestValidationInput, _ := o.getRequestValidationInput(r)
   241  	if requestValidationInput != nil {
   242  		responseHeaders := http.Header{HeaderContentType: []string{contentType}}
   243  		responseCode := 200
   244  
   245  		responseValidationInput := &openapi3filter.ResponseValidationInput{
   246  			RequestValidationInput: requestValidationInput,
   247  			Status:                 responseCode,
   248  			Header:                 responseHeaders,
   249  		}
   250  		responseValidationInput.SetBodyBytes(body)
   251  		err := openapi3filter.ValidateResponse(context.Background(), responseValidationInput)
   252  		if err != nil {
   253  			return fmt.Errorf("response doesn't conform to OpenAPI spec: %w", err)
   254  		}
   255  	}
   256  	return nil
   257  }
   258  
   259  func (o *OpenAPI) getRequestValidationInput(r *http.Request) (*openapi3filter.RequestValidationInput, error) {
   260  	route, pathParams, err := o.router.FindRoute(r)
   261  	if err != nil {
   262  		log.Printf("route not found in OpenAPI spec for url %s (host: %s), "+
   263  			"skipping OpenAPI validation", r.URL, r.Host)
   264  		return nil, err
   265  	}
   266  	opts := &openapi3filter.Options{
   267  		SkipSettingDefaults: true,
   268  	}
   269  	opts.WithCustomSchemaErrorFunc(func(err *openapi3.SchemaError) string {
   270  		return err.Reason
   271  	})
   272  	return &openapi3filter.RequestValidationInput{
   273  		Request:    r,
   274  		PathParams: pathParams,
   275  		Route:      route,
   276  		Options:    opts,
   277  	}, nil
   278  }
   279  
   280  // normalizeBaseURL normalizes the given base URL so our OpenAPI validator is able to match
   281  // requests against the OpenAPI spec. This involves:
   282  //
   283  //   - striping the context root (path) from the base URL. If you use a context root we expect
   284  //     you to have a proxy fronting GoKoala, therefore we also  need to strip it from the base
   285  //     URL used during OpenAPI validation
   286  //
   287  //   - replacing HTTPS scheme with HTTP. Since GoKoala doesn't support HTTPS we always perform
   288  //     OpenAPI validation against HTTP requests. Note: it's possible to offer GoKoala over HTTPS, but you'll
   289  //     need to take care of that in your proxy server (or loadbalancer/service mesh/etc) fronting GoKoala.
   290  func normalizeBaseURL(baseURL string) string {
   291  	serverURL, _ := url.Parse(baseURL)
   292  	result := strings.Replace(baseURL, serverURL.Scheme, "http", 1)
   293  	result = strings.Replace(result, serverURL.Path, "", 1)
   294  	return result
   295  }