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  }