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 }