refactor: extract http handlers to struct methods
1 file changed, 19 insertions(+), 330 deletions(-)
changed files
M internal/server/mux.go → internal/server/mux.go
@@ -2,30 +2,19 @@ package server import ( "cmp" - "context" - "encoding/xml" - "fmt" "maps" - "math" "net/http" "path" "slices" - "strconv" "strings" "alin.ovh/searchix/frontend" - "alin.ovh/searchix/internal/components" "alin.ovh/searchix/internal/config" - search "alin.ovh/searchix/internal/index" - "alin.ovh/searchix/internal/opensearch" - "alin.ovh/searchix/internal/pagination" "alin.ovh/searchix/internal/sentryhttp" "alin.ovh/x/log" "github.com/Southclaws/fault" - "github.com/Southclaws/fault/fctx" "github.com/Southclaws/fault/fmsg" - "github.com/Southclaws/fault/ftag" "github.com/osdevisnot/sorvor/pkg/livereload" )@@ -69,341 +58,42 @@ } if options.ReadIndex == nil { return nil, fault.New("read index is nil") } - index := options.ReadIndex sortSources(cfg.Importer.Sources) assets, err := frontend.New() if err != nil { return nil, fault.Wrap(err, fmsg.With("could not create frontend asset collection")) } - errorHandler := createErrorHandler(cfg, assets, log) + handler := &GlobalHandler{ + assets: assets, + cfg: cfg, + log: log, + index: options.ReadIndex, + mdb: options.ManpagesURLMap, + errorHandler: createErrorHandler(cfg, assets, log), + } - top := http.NewServeMux() mux := sentryhttp.NewServeMux() - createSearchHandler := func(importerType config.ImporterType) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - var err error - var source *config.Source - if importerType != config.All { - source = cfg.Importer.Sources[r.PathValue("source")] - if source == nil || importerType != source.Importer { - errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - } - - facets := r.URL.Query() - facets.Del("query") - facets.Del("page") - - if r.URL.Query().Has("query") || len(facets) > 0 { - qs := r.URL.Query().Get("query") - ctx := r.Context() - if qs != "" { - ctx = fctx.WithMeta(ctx, "search_query", qs) - } - - if len(qs) < 2 && len(facets) == 0 { - errorHandler(w, r, "Query too short", http.StatusBadRequest) - - return - } - - pageSize := search.DefaultPageSize - pageNumber := 1 - if pg := r.URL.Query().Get("page"); pg != "" { - pageNumber, err = strconv.Atoi(pg) - if err != nil || pageNumber > math.MaxInt { - errorHandler(w, r, "Bad query string", http.StatusBadRequest) - - return - } - if pageNumber == 0 { - pageNumber = 1 - pageSize = config.MaxResultsShowAll - } - } - page := pagination.New(pageNumber, pageSize) - - ctx, cancel := context.WithTimeout(ctx, cfg.Web.SearchTimeout.Duration) - results, err := index.Search(ctx, source, qs, page.From, page.Size, facets) - cancel() - - if err != nil { - if err == context.DeadlineExceeded { - errorHandler(w, r, "Search timed out", http.StatusInternalServerError) + // Register global routes + mux.HandleFunc("/{$}", handler.CombinedSearch) + mux.HandleFunc("/opensearch.xml", handler.RootOpenSearch) - return - } - meta := fctx.Unwrap(err) - log.Error("search error", "error", err, "search_query", meta["search_query"]) - errorHandler(w, r, err.Error(), http.StatusInternalServerError) - - return - } - if pageSize == config.MaxResultsShowAll && - results.Total > config.MaxResultsShowAll { - errorHandler(w, r, "Too many results, use pagination", http.StatusBadRequest) - } - page.SetResults(results.Total) - - tdata := components.ResultData{ - TemplateData: components.TemplateData{ - ExtraHeadHTML: cfg.Web.ExtraHeadHTML, - Source: source, - Sources: sources, - Assets: assets, - Query: qs, - SearchNav: components.NewSearchNav(*r.URL).WithPagination(page), - }, - Query: qs, - FacetQueries: facets, - Results: results, - } - - w.Header().Add("Cache-Control", "max-age=300") - w.Header().Add("Vary", "Fetch") - var baseErr error - if r.Header.Get("Fetch") == "true" { - w.Header().Add("Content-Type", "text/html; charset=utf-8") - baseErr = components.Results(tdata).Render(w) - } else { - baseErr = components.ResultsPage(tdata).Render(w) - } - if baseErr != nil { - log.Error("template error", "template", importerType, "error", baseErr) - errorHandler(w, r, baseErr.Error(), http.StatusInternalServerError) - } - } else { - w.Header().Add("Cache-Control", "max-age=14400") - err = components.SearchPage( - components.TemplateData{ - ExtraHeadHTML: cfg.Web.ExtraHeadHTML, - Sources: sources, - Source: source, - Assets: assets, - }, - components.ResultData{}, - ).Render(w) - if err != nil { - errorHandler(w, r, err.Error(), http.StatusInternalServerError) - - return - } - } - } - } - - mux.HandleFunc("/{$}", createSearchHandler(config.All)) - mux.HandleFunc("/options/{source}/search", createSearchHandler(config.Options)) - mux.HandleFunc("/packages/{source}/search", createSearchHandler(config.Packages)) + mux.HandleFunc("/", handler.StaticFile) + mux.HandleFunc("/assets/", handler.Asset) + mux.HandleFunc("/man/{section}/{page}", handler.ManualPage) mux.Handle("/all/search", http.RedirectHandler("/", http.StatusFound)) - - createSourceIDHandler := func(importerType config.ImporterType) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - source := cfg.Importer.Sources[r.PathValue("source")] - if source == nil || source.Importer != importerType { - errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - importerSingular := importerType.Singular() - - doc, err := index.GetDocument(r.Context(), source, r.PathValue("id")) - if err != nil { - if ftag.Get(err) == ftag.NotFound { - log.Warn("document not found", "source", source.Key, "id", r.PathValue("id")) - errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - - log.Error( - "failed to get document", - "source", - source.Key, - "id", - r.PathValue("id"), - "error", - err, - ) - errorHandler( - w, - r, - http.StatusText(http.StatusInternalServerError), - http.StatusInternalServerError, - ) - - return - } - - if doc == nil { - errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - - tdata := components.TemplateData{ - ExtraHeadHTML: cfg.Web.ExtraHeadHTML, - Source: source, - Sources: sources, - Assets: assets, - } - var baseErr error - if r.Header.Get("Fetch") == "true" { - w.Header().Add("Content-Type", "text/html; charset=utf-8") - baseErr = components.Detail(doc).Render(w) - } else { - baseErr = components.DetailPage(tdata, doc).Render(w) - } - if baseErr != nil { - log.Error("template error", "template", importerSingular, "error", baseErr) - errorHandler(w, r, baseErr.Error(), http.StatusInternalServerError) - } - } - } - mux.HandleFunc("/options/{source}/{id}", createSourceIDHandler(config.Options)) - mux.HandleFunc("/packages/{source}/{id}", createSourceIDHandler(config.Packages)) - mux.HandleFunc("/option/{source}/{id}", createSourceIDHandler(config.Options)) - mux.HandleFunc("/package/{source}/{id}", createSourceIDHandler(config.Packages)) - - createOpenSearchXMLHandler := func(importerType config.ImporterType) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - source := cfg.Importer.Sources[r.PathValue("source")] - if source == nil || importerType != source.Importer { - errorHandler(w, r, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - - w.Header().Add("Cache-Control", "max-age=604800") - w.Header().Set("Content-Type", "application/opensearchdescription+xml") - osd := &opensearch.Description{ - ShortName: fmt.Sprintf("Searchix %s", source), - LongName: fmt.Sprintf("Search %s with Searchix", source), - Description: fmt.Sprintf("Search %s", source), - SearchForm: cfg.Web.BaseURL.JoinPath( - source.Importer.String(), - source.Key, - "search", - ), - Image: opensearch.Image{ - Height: 32, - Width: 32, - Type: "image/x-icon", - Content: cfg.Web.BaseURL.JoinPath("favicon.ico").String(), - }, - URL: opensearch.URL{ - Method: "get", - Type: "text/html", - Template: cfg.Web.BaseURL.JoinPath( - source.Importer.String(), - source.Key, - "search", - ).AddRawQuery("query", "{searchTerms}"), - }, - } - enc := xml.NewEncoder(w) - enc.Indent("", " ") - err := enc.Encode(osd) - if err != nil { - // no errorHandler; HTML does not make sense here - http.Error( - w, - fmt.Sprintf("OpenSearch XML encoding error: %v", err), - http.StatusInternalServerError, - ) - } - } - } - - mux.HandleFunc("/options/{source}/opensearch.xml", createOpenSearchXMLHandler(config.Options)) - mux.HandleFunc("/packages/{source}/opensearch.xml", createOpenSearchXMLHandler(config.Packages)) mux.Handle("/all/opensearch.xml", http.RedirectHandler("/opensearch.xml", http.StatusFound)) - mux.HandleFunc("/opensearch.xml", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Add("Cache-Control", "max-age=604800") - w.Header().Set("Content-Type", "application/opensearchdescription+xml") - osd := &opensearch.Description{ - ShortName: "Searchix Combined", - LongName: "Search nix options and packages with Searchix", - Description: "Search nix options and packages with Searchix", - SearchForm: cfg.Web.BaseURL.JoinPath(), - Image: opensearch.Image{ - Height: 32, - Width: 32, - Type: "image/x-icon", - Content: cfg.Web.BaseURL.JoinPath("favicon.ico").String(), - }, - URL: opensearch.URL{ - Method: "get", - Type: "text/html", - Template: cfg.Web.BaseURL.JoinPath(). - AddRawQuery("query", "{searchTerms}"), - }, - } - enc := xml.NewEncoder(w) - enc.Indent("", " ") - err := enc.Encode(osd) - if err != nil { - // no errorHandler; HTML does not make sense here - http.Error( - w, - fmt.Sprintf("OpenSearch XML encoding error: %v", err), - http.StatusInternalServerError, - ) - } - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/static") - asset, found := assets.ByPath[path] - if !found { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + for _, source := range cfg.Importer.Sources { + sh := handler.NewSourceHandler(source) + importerPath := path.Join("/", source.Importer.String(), source.Key) - return - } - // optimisation for HTTP/3: first header sent as byte(38), not the string - // see https://datatracker.ietf.org/doc/html/rfc9204#appendix-A for values - w.Header().Add("Cache-Control", "max-age=604800") - w.Header().Add("ETag", asset.ETag) - http.ServeFileFS(w, r, frontend.Files, asset.Filename) - }) - - mux.HandleFunc("/assets/", func(w http.ResponseWriter, r *http.Request) { - asset, found := assets.ByImmutablePath[r.URL.Path] - if !found { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - // optimisation for HTTP/3: first header sent as byte(41), not the string - // see https://datatracker.ietf.org/doc/html/rfc9204#appendix-A for values - w.Header().Add("Cache-Control", "public, max-age=31536000") - w.Header().Add("Cache-Control", "immutable") - http.ServeFileFS(w, r, frontend.Files, asset.Filename) - }) - - mdb := options.ManpagesURLMap - if err := mdb.Open(); err != nil { - return nil, fault.Wrap(err, fmsg.With("failed to open manpages URL map")) + mux.Handle(importerPath+"/", http.StripPrefix(importerPath, sh)) } - mux.HandleFunc("/man/{section}/{page}", func(w http.ResponseWriter, r *http.Request) { - section := r.PathValue("section") - page := r.PathValue("page") - url, ok := mdb.Get(section, page) - if !ok { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - - return - } - - http.Redirect(w, r, url, http.StatusTemporaryRedirect) - }) - + top := http.NewServeMux() if liveReload { applyDevModeOverrides(cfg) cfg.Web.ExtraHeadHTML = livereload.JsSnippet@@ -419,7 +109,6 @@ if err != nil { return nil, fault.Wrap(err, fmsg.With("could not add directory to file watcher")) } go fw.Start(func(filename string) { - log.Debug(fmt.Sprintf("got filename %s", filename)) if match, _ := path.Match("frontend/static/*", filename); match { err := assets.Rehash() if err != nil {