github.com/PDOK/gokoala@v0.50.6/internal/engine/contentnegotiation.go (about) 1 package engine 2 3 import ( 4 "log" 5 "net/http" 6 7 "github.com/PDOK/gokoala/config" 8 "github.com/PDOK/gokoala/internal/engine/util" 9 "github.com/elnormous/contenttype" 10 "golang.org/x/text/language" 11 ) 12 13 const ( 14 FormatParam = "f" 15 languageParam = "lang" 16 17 MediaTypeJSON = "application/json" 18 MediaTypeHTML = "text/html" 19 MediaTypeTileJSON = "application/vnd.mapbox.tile+json" 20 MediaTypeMVT = "application/vnd.mapbox-vector-tile" 21 MediaTypeMapboxStyle = "application/vnd.mapbox.style+json" 22 MediaTypeSLD = "application/vnd.ogc.sld+xml;version=1.0" 23 MediaTypeOpenAPI = "application/vnd.oai.openapi+json;version=3.0" 24 MediaTypeGeoJSON = "application/geo+json" 25 MediaTypeJSONFG = "application/vnd.ogc.fg+json" // https://docs.ogc.org/per/21-017r1.html#toc17 26 MediaTypeQuantizedMesh = "application/vnd.quantized-mesh" 27 28 FormatHTML = "html" 29 FormatJSON = "json" 30 FormatTileJSON = "tilejson" 31 FormatMVT = "mvt" 32 FormatMVTAlternative = "pbf" 33 FormatMapboxStyle = "mapbox" 34 FormatSLD = "sld10" 35 FormatGeoJSON = "geojson" // ?=json should also work for geojson 36 FormatJSONFG = "jsonfg" 37 FormatGzip = "gzip" 38 ) 39 40 var ( 41 MediaTypeJSONFamily = []string{MediaTypeTileJSON, MediaTypeMapboxStyle, MediaTypeGeoJSON, MediaTypeJSONFG} 42 OutputFormatDefault = map[string]string{FormatJSON: "JSON"} 43 OutputFormatFeatures = map[string]string{FormatJSON: "GeoJSON", FormatJSONFG: "JSON-FG"} 44 CompressibleMediaTypes = []string{ 45 MediaTypeJSON, 46 MediaTypeGeoJSON, 47 MediaTypeJSONFG, 48 MediaTypeTileJSON, 49 MediaTypeMapboxStyle, 50 MediaTypeOpenAPI, 51 MediaTypeHTML, 52 // common web media types 53 "text/css", 54 "text/plain", 55 "text/javascript", 56 "application/javascript", 57 "image/svg+xml", 58 } 59 StyleFormatExtension = map[string]string{ 60 FormatMapboxStyle: ".json", 61 FormatSLD: ".sld", 62 } 63 ) 64 65 type ContentNegotiation struct { 66 availableMediaTypes []contenttype.MediaType 67 availableLanguages []language.Tag 68 69 formatsByMediaType map[string]string 70 mediaTypesByFormat map[string]string 71 } 72 73 func newContentNegotiation(availableLanguages []config.Language) *ContentNegotiation { 74 availableMediaTypes := []contenttype.MediaType{ 75 // in order 76 contenttype.NewMediaType(MediaTypeJSON), 77 contenttype.NewMediaType(MediaTypeHTML), 78 contenttype.NewMediaType(MediaTypeTileJSON), 79 contenttype.NewMediaType(MediaTypeGeoJSON), 80 contenttype.NewMediaType(MediaTypeJSONFG), 81 contenttype.NewMediaType(MediaTypeMVT), 82 contenttype.NewMediaType(MediaTypeMapboxStyle), 83 contenttype.NewMediaType(MediaTypeSLD), 84 } 85 86 formatsByMediaType := map[string]string{ 87 MediaTypeJSON: FormatJSON, 88 MediaTypeHTML: FormatHTML, 89 MediaTypeTileJSON: FormatTileJSON, 90 MediaTypeGeoJSON: FormatGeoJSON, 91 MediaTypeJSONFG: FormatJSONFG, 92 MediaTypeMVT: FormatMVT, 93 MediaTypeMapboxStyle: FormatMapboxStyle, 94 MediaTypeSLD: FormatSLD, 95 } 96 97 mediaTypesByFormat := util.ReverseMap(formatsByMediaType) 98 99 languageTags := make([]language.Tag, 0, len(availableLanguages)) 100 for _, availableLanguage := range availableLanguages { 101 languageTags = append(languageTags, availableLanguage.Tag) 102 } 103 104 return &ContentNegotiation{ 105 availableMediaTypes: availableMediaTypes, 106 availableLanguages: languageTags, 107 formatsByMediaType: formatsByMediaType, 108 mediaTypesByFormat: mediaTypesByFormat, 109 } 110 } 111 112 func (cn *ContentNegotiation) GetSupportedStyleFormats() []string { 113 return []string{FormatMapboxStyle, FormatSLD} 114 } 115 116 func (cn *ContentNegotiation) GetStyleFormatExtension(format string) string { 117 if extension, exists := StyleFormatExtension[format]; exists { 118 return extension 119 } 120 return "" 121 } 122 123 // NegotiateFormat performs content negotiation, not idempotent (since it removes the ?f= param) 124 func (cn *ContentNegotiation) NegotiateFormat(req *http.Request) string { 125 requestedFormat := cn.getFormatFromQueryParam(req) 126 if requestedFormat == "" { 127 requestedFormat = cn.getFormatFromAcceptHeader(req) 128 } 129 if requestedFormat == "" { 130 requestedFormat = FormatJSON // default 131 } 132 return requestedFormat 133 } 134 135 // NegotiateLanguage performs language negotiation, not idempotent (since it removes the ?lang= param) 136 func (cn *ContentNegotiation) NegotiateLanguage(w http.ResponseWriter, req *http.Request) language.Tag { 137 requestedLanguage := cn.getLanguageFromQueryParam(w, req) 138 if requestedLanguage == language.Und { 139 requestedLanguage = cn.getLanguageFromCookie(req) 140 } 141 if requestedLanguage == language.Und { 142 requestedLanguage = cn.getLanguageFromHeader(req) 143 } 144 if requestedLanguage == language.Und { 145 requestedLanguage = language.Dutch // default 146 } 147 return requestedLanguage 148 } 149 150 func (cn *ContentNegotiation) formatToMediaType(format string) string { 151 return cn.mediaTypesByFormat[format] 152 } 153 154 func (cn *ContentNegotiation) getFormatFromQueryParam(req *http.Request) string { 155 var requestedFormat = "" 156 queryParams := req.URL.Query() 157 if queryParams.Get(FormatParam) != "" { 158 requestedFormat = queryParams.Get(FormatParam) 159 160 // remove ?f= parameter, to prepare for rewrite 161 queryParams.Del(FormatParam) 162 req.URL.RawQuery = queryParams.Encode() 163 } 164 return requestedFormat 165 } 166 167 func (cn *ContentNegotiation) getFormatFromAcceptHeader(req *http.Request) string { 168 accepted, _, err := contenttype.GetAcceptableMediaType(req, cn.availableMediaTypes) 169 if err != nil { 170 log.Printf("Failed to parse Accept header: %v. Continuing\n", err) 171 return "" 172 } 173 return cn.formatsByMediaType[accepted.String()] 174 } 175 176 func (cn *ContentNegotiation) getLanguageFromQueryParam(w http.ResponseWriter, req *http.Request) language.Tag { 177 var requestedLanguage = language.Und 178 queryParams := req.URL.Query() 179 if queryParams.Get(languageParam) != "" { 180 lang := queryParams.Get(languageParam) 181 accepted, _, err := language.ParseAcceptLanguage(lang) 182 if err != nil { 183 return requestedLanguage 184 } 185 m := language.NewMatcher(cn.availableLanguages) 186 _, langIndex, _ := m.Match(accepted...) 187 requestedLanguage = cn.availableLanguages[langIndex] 188 // override for use in cookie 189 lang = requestedLanguage.String() 190 191 // set requested language in cookie 192 setLanguageCookie(w, lang) 193 194 // remove ?lang= parameter, to prepare for rewrite 195 queryParams.Del(languageParam) 196 req.URL.RawQuery = queryParams.Encode() 197 } 198 return requestedLanguage 199 } 200 201 func setLanguageCookie(w http.ResponseWriter, lang string) { 202 cookie := &http.Cookie{ 203 Name: languageParam, 204 Value: lang, 205 Path: "/", 206 MaxAge: config.CookieMaxAge, 207 SameSite: http.SameSiteStrictMode, 208 Secure: true, 209 } 210 http.SetCookie(w, cookie) 211 } 212 213 func (cn *ContentNegotiation) getLanguageFromCookie(req *http.Request) language.Tag { 214 var requestedLanguage = language.Und 215 cookie, err := req.Cookie(languageParam) 216 if err != nil { 217 return requestedLanguage 218 } 219 lang := cookie.Value 220 accepted, _, err := language.ParseAcceptLanguage(lang) 221 if err != nil { 222 return requestedLanguage 223 } 224 m := language.NewMatcher(cn.availableLanguages) 225 _, langIndex, _ := m.Match(accepted...) 226 requestedLanguage = cn.availableLanguages[langIndex] 227 return requestedLanguage 228 } 229 230 func (cn *ContentNegotiation) getLanguageFromHeader(req *http.Request) language.Tag { 231 var requestedLanguage = language.Und 232 if req.Header.Get(HeaderAcceptLanguage) != "" { 233 accepted, _, err := language.ParseAcceptLanguage(req.Header.Get(HeaderAcceptLanguage)) 234 if err != nil { 235 log.Printf("Failed to parse Accept-Language header: %v. Continuing\n", err) 236 return requestedLanguage 237 } 238 m := language.NewMatcher(cn.availableLanguages) 239 _, langIndex, _ := m.Match(accepted...) 240 requestedLanguage = cn.availableLanguages[langIndex] 241 } 242 return requestedLanguage 243 }