internal/scan: add generalized param parsing

Add a function that parses query params according to struct fields.

Also add a function that parses just the URL path into module, version
and suffix.

Change-Id: I219b3953462b0c3614d8da9147749b27ce5f6833
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/467215
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julieqiu@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/internal/queue/queue_test.go b/internal/queue/queue_test.go
index ded8d00..be5ec81 100644
--- a/internal/queue/queue_test.go
+++ b/internal/queue/queue_test.go
@@ -64,11 +64,15 @@
 		TaskNameSuffix: "suf",
 	}
 	sreq := &scan.Request{
-		Module:     "mod",
-		Version:    "v1.2.3",
-		ImportedBy: 0,
-		Mode:       "test",
-		Insecure:   true,
+		ModuleURLPath: scan.ModuleURLPath{
+			Module:  "mod",
+			Version: "v1.2.3",
+		},
+		RequestParams: scan.RequestParams{
+			ImportedBy: 0,
+			Mode:       "test",
+			Insecure:   true,
+		},
 	}
 	got, err := gcp.newTaskRequest(sreq, opts)
 	if err != nil {
diff --git a/internal/scan/parse.go b/internal/scan/parse.go
index be59220..76aeaaa 100644
--- a/internal/scan/parse.go
+++ b/internal/scan/parse.go
@@ -7,9 +7,11 @@
 
 import (
 	"bufio"
+	"errors"
 	"fmt"
 	"net/http"
 	"os"
+	"reflect"
 	"strconv"
 	"strings"
 
@@ -20,14 +22,15 @@
 // Request contains information passed
 // to a scan endpoint.
 type Request struct {
-	Module     string
-	Version    string
-	Suffix     string
+	ModuleURLPath
+	RequestParams
+}
+
+// RequestParams has query parameters for a scan request.
+type RequestParams struct {
 	ImportedBy int
 	Mode       string
 	Insecure   bool
-
-	// TODO: support optional parameters?
 }
 
 func (r *Request) URLPathAndParams() string {
@@ -55,55 +58,27 @@
 //   - <module>/@latest
 //
 // (These are the same forms that the module proxy accepts.)
-func ParseRequest(r *http.Request, scanPrefix string) (*Request, error) {
-	mod, vers, suff, err := ParseModuleVersionSuffix(strings.TrimPrefix(r.URL.Path, scanPrefix))
+func ParseRequest(r *http.Request, scanPrefix string) (_ *Request, err error) {
+	defer derrors.Wrap(&err, "ParseRequest(%s)", scanPrefix)
+
+	mp, err := ParseModuleURLPath(strings.TrimPrefix(r.URL.Path, scanPrefix))
 	if err != nil {
 		return nil, err
 	}
-	importedBy, err := ParseRequiredIntParam(r, "importedby")
-	if err != nil {
+
+	rp := RequestParams{ImportedBy: -1} // use -1 to detect missing param (explicit 0 is OK)
+	if err := ParseParams(r, &rp); err != nil {
 		return nil, err
 	}
-	insecure, err := ParseOptionalBoolParam(r, "insecure", false)
-	if err != nil {
-		return nil, err
+	if rp.ImportedBy < 0 {
+		return nil, errors.New(`missing or negative "importedby" query param`)
 	}
 	return &Request{
-		Module:     mod,
-		Version:    vers,
-		Suffix:     suff,
-		ImportedBy: importedBy,
-		Mode:       ParseMode(r),
-		Insecure:   insecure,
+		ModuleURLPath: mp,
+		RequestParams: rp,
 	}, nil
 }
 
-// ParseModuleVersionSuffix returns the module path, version and suffix described by
-// the argument. The suffix is the part of the path after the version.
-func ParseModuleVersionSuffix(requestPath string) (path, vers, suffix string, err error) {
-	p := strings.TrimPrefix(requestPath, "/")
-	modulePath, versionAndSuffix, found := strings.Cut(p, "@")
-	if !found {
-		return "", "", "", fmt.Errorf("invalid path %q: missing '@'", requestPath)
-	}
-	modulePath = strings.TrimSuffix(modulePath, "/")
-	if modulePath == "" {
-		return "", "", "", fmt.Errorf("invalid path %q: missing module", requestPath)
-	}
-	if strings.HasPrefix(versionAndSuffix, "v/") {
-		versionAndSuffix = versionAndSuffix[2:]
-	}
-	// Now versionAndSuffix begins with a version.
-	version, suffix, _ := strings.Cut(versionAndSuffix, "/")
-	if version == "" {
-		return "", "", "", fmt.Errorf("invalid path %q: missing version", requestPath)
-	}
-	if version[0] != 'v' {
-		version = "v" + version
-	}
-	return modulePath, version, suffix, nil
-}
-
 func ParseRequiredIntParam(r *http.Request, name string) (int, error) {
 	value := r.FormValue(name)
 	if value == "" {
@@ -204,3 +179,107 @@
 	}
 	return lines, nil
 }
+
+// A ModuleURLPath holds the components of a URL path parsed
+// as module, version and suffix.
+type ModuleURLPath struct {
+	Module  string
+	Version string
+	Suffix  string
+}
+
+// ParseModuleURLPath parse the module path, version and suffix described by
+// the argument, which is expected to be a URL path.
+// The module and version should have one of the following three forms:
+//   - <module>/@v/<version>
+//   - <module>@<version>
+//   - <module>/@latest
+//
+// The suffix is the part of the path after the version.
+func ParseModuleURLPath(requestPath string) (_ ModuleURLPath, err error) {
+	defer derrors.Wrap(&err, "ParseModuleURLPath(%q)", requestPath)
+
+	p := strings.TrimPrefix(requestPath, "/")
+	modulePath, versionAndSuffix, found := strings.Cut(p, "@")
+	if !found {
+		return ModuleURLPath{}, fmt.Errorf("invalid path %q: missing '@'", requestPath)
+	}
+	modulePath = strings.TrimSuffix(modulePath, "/")
+	if modulePath == "" {
+		return ModuleURLPath{}, fmt.Errorf("invalid path %q: missing module", requestPath)
+	}
+	if strings.HasPrefix(versionAndSuffix, "v/") {
+		versionAndSuffix = versionAndSuffix[2:]
+	}
+	// Now versionAndSuffix begins with a version.
+	version, suffix, _ := strings.Cut(versionAndSuffix, "/")
+	if version == "" {
+		return ModuleURLPath{}, fmt.Errorf("invalid path %q: missing version", requestPath)
+	}
+	if version[0] != 'v' {
+		version = "v" + version
+	}
+	return ModuleURLPath{modulePath, version, suffix}, nil
+}
+
+// Path reconstructs a URL path from m.
+func (m ModuleURLPath) Path() string {
+	p := m.Module + "@" + m.Version
+	if m.Suffix != "" {
+		p += "/" + m.Suffix
+	}
+	return p
+}
+
+// ParseParams populates the fields of pstruct, which must a pointer to a struct,
+// with the form and query parameters of r.
+//
+// The fields of pstruct must be exported, and each field must be a string, an
+// int or a bool. If there is a request parameter corresponding to the
+// lower-cased field name, it is parsed according to the field's type and
+// assigned to the field. If there is no matching parameter (or it is the empty
+// string), the field is not assigned.
+//
+// For default values or to detect missing parameters, set the struct field
+// before calling ParseParams; if there is no matching parameter, the field will
+// retain its value.
+func ParseParams(r *http.Request, pstruct any) (err error) {
+	defer derrors.Wrap(&err, "ParseParams(%q)", r.URL)
+
+	v := reflect.ValueOf(pstruct)
+	t := v.Type()
+	if t.Kind() != reflect.Pointer || t.Elem().Kind() != reflect.Struct {
+		return fmt.Errorf("need struct pointer, got %T", pstruct)
+	}
+	t = t.Elem()
+	v = v.Elem()
+
+	for i := 0; i < t.NumField(); i++ {
+		f := t.Field(i)
+		paramName := strings.ToLower(f.Name)
+		param := r.FormValue(paramName)
+		if param == "" {
+			// If param is missing, do not set field.
+			continue
+		}
+		pval, err := parseParam(param, f.Type.Kind())
+		if err != nil {
+			return fmt.Errorf("param %s: %v", paramName, err)
+		}
+		v.Field(i).Set(reflect.ValueOf(pval))
+	}
+	return nil
+}
+
+func parseParam(param string, kind reflect.Kind) (any, error) {
+	switch kind {
+	case reflect.String:
+		return param, nil
+	case reflect.Int:
+		return strconv.Atoi(param)
+	case reflect.Bool:
+		return strconv.ParseBool(param)
+	default:
+		return nil, fmt.Errorf("cannot parse kind %s", kind)
+	}
+}
diff --git a/internal/scan/parse_test.go b/internal/scan/parse_test.go
index 5397e8f..19f86d2 100644
--- a/internal/scan/parse_test.go
+++ b/internal/scan/parse_test.go
@@ -7,6 +7,8 @@
 import (
 	"net/http"
 	"net/url"
+	"reflect"
+	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -23,41 +25,32 @@
 			name: "ValidScanURL",
 			url:  "https://worker.com/scan/module/@v/v1.0.0?importedby=50",
 			want: Request{
-				Module:     "module",
-				Version:    "v1.0.0",
-				ImportedBy: 50,
-				Mode:       "",
+				ModuleURLPath{Module: "module", Version: "v1.0.0"},
+				RequestParams{ImportedBy: 50, Mode: ""},
 			},
 		},
 		{
 			name: "ValidImportsOnlyScanURL",
 			url:  "https://worker.com/scan/module/@v/v1.0.0-abcdefgh?importedby=100&mode=mode1",
 			want: Request{
-				Module:     "module",
-				Version:    "v1.0.0-abcdefgh",
-				ImportedBy: 100,
-				Mode:       "mode1",
+				ModuleURLPath{Module: "module", Version: "v1.0.0-abcdefgh"},
+				RequestParams{ImportedBy: 100, Mode: "mode1"},
 			},
 		},
 		{
 			name: "Module@Version",
 			url:  "https://worker.com/scan/module@v1.2.3?importedby=1",
 			want: Request{
-				Module:     "module",
-				Version:    "v1.2.3",
-				ImportedBy: 1,
-				Mode:       "",
+				ModuleURLPath{Module: "module", Version: "v1.2.3"},
+				RequestParams{ImportedBy: 1, Mode: ""},
 			},
 		},
 		{
 			name: "Module@Version suffix",
 			url:  "https://worker.com/scan/module@v1.2.3/path/to/dir?importedby=1",
 			want: Request{
-				Module:     "module",
-				Version:    "v1.2.3",
-				Suffix:     "path/to/dir",
-				ImportedBy: 1,
-				Mode:       "",
+				ModuleURLPath{Module: "module", Version: "v1.2.3", Suffix: "path/to/dir"},
+				RequestParams{ImportedBy: 1, Mode: ""},
 			},
 		},
 	} {
@@ -112,12 +105,12 @@
 		{
 			name: "MissingImportedBy",
 			url:  "/module/@v/v1.0.0",
-			want: `missing query param "importedby"`,
+			want: `missing or negative "importedby" query param`,
 		},
 		{
 			name: "BadImportedBy",
 			url:  "/module@v1?importedby=1a",
-			want: `want integer for "importedby" query param, got "1a"`,
+			want: `param importedby: strconv.Atoi: parsing "1a": invalid syntax`,
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
@@ -127,7 +120,7 @@
 			}
 			r := &http.Request{URL: u}
 			if _, err := ParseRequest(r, "/scan"); err != nil {
-				if got := err.Error(); got != test.want {
+				if got := err.Error(); !strings.Contains(got, test.want) {
 					t.Fatalf("\ngot  %s\nwant %s", got, test.want)
 				}
 			} else {
@@ -162,3 +155,68 @@
 		t.Errorf("\n got %v\nwant %v", got, want)
 	}
 }
+
+func TestParseParams(t *testing.T) {
+	type S struct {
+		Str  string
+		Int  int
+		Bool bool
+	}
+
+	t.Run("success", func(t *testing.T) {
+		for _, test := range []struct {
+			params string
+			want   S
+		}{
+			{
+				"str=foo&int=1&bool=true",
+				S{Str: "foo", Int: 1, Bool: true},
+			},
+			{
+				"", // all defaults
+				S{Str: "d", Int: 17, Bool: false},
+			},
+			{
+				"int=3&bool=t&str=", // empty string is same as default
+				S{Str: "d", Int: 3, Bool: true},
+			},
+		} {
+			r, err := http.NewRequest("GET", "https://path?"+test.params, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			got := S{Str: "d", Int: 17} // set defaults
+			if err := ParseParams(r, &got); err != nil {
+				t.Fatal(err)
+			}
+			if !reflect.DeepEqual(got, test.want) {
+				t.Errorf("%q: \ngot  %+v\nwant %+v", test.params, got, test.want)
+			}
+		}
+	})
+	t.Run("errors", func(t *testing.T) {
+		for _, test := range []struct {
+			arg         any
+			params      string
+			errContains string
+		}{
+			{3, "", "struct pointer"},
+			{&S{}, "int=foo", "invalid syntax"},
+			{&S{}, "bool=foo", "invalid syntax"},
+			{&struct{ F float64 }{}, "f=1.1", "cannot parse kind"},
+		} {
+			r, err := http.NewRequest("GET", "https://path?"+test.params, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			err = ParseParams(r, test.arg)
+			got := "<nil>"
+			if err != nil {
+				got = err.Error()
+			}
+			if !strings.Contains(got, test.errContains) {
+				t.Errorf("%v, %q: got %q, want string containing %q", test.arg, test.params, got, test.errContains)
+			}
+		}
+	})
+}
diff --git a/internal/worker/enqueue.go b/internal/worker/enqueue.go
index 20f6867..d80f09f 100644
--- a/internal/worker/enqueue.go
+++ b/internal/worker/enqueue.go
@@ -79,10 +79,8 @@
 	var sreqs []*scan.Request
 	for _, ms := range modspecs {
 		sreqs = append(sreqs, &scan.Request{
-			Module:     ms.Path,
-			Version:    ms.Version,
-			ImportedBy: ms.ImportedBy,
-			Mode:       mode,
+			ModuleURLPath: scan.ModuleURLPath{Module: ms.Path, Version: ms.Version},
+			RequestParams: scan.RequestParams{ImportedBy: ms.ImportedBy, Mode: mode},
 		})
 	}
 	return sreqs
diff --git a/internal/worker/vulncheck_enqueue.go b/internal/worker/vulncheck_enqueue.go
index bf93a8f..778b252 100644
--- a/internal/worker/vulncheck_enqueue.go
+++ b/internal/worker/vulncheck_enqueue.go
@@ -121,15 +121,13 @@
 		if err != nil {
 			return nil, err
 		}
-		mod, vers, suff, err := scan.ParseModuleVersionSuffix(strings.TrimPrefix(attrs.Name, binaryDir+"/"))
+		mp, err := scan.ParseModuleURLPath(strings.TrimPrefix(attrs.Name, binaryDir+"/"))
 		if err != nil {
 			return nil, err
 		}
 		sreqs = append(sreqs, &scan.Request{
-			Module:  mod,
-			Version: vers,
-			Suffix:  suff,
-			Mode:    ModeBinary,
+			ModuleURLPath: mp,
+			RequestParams: scan.RequestParams{Mode: ModeBinary},
 		})
 	}
 	return sreqs, nil
diff --git a/internal/worker/vulncheck_enqueue_test.go b/internal/worker/vulncheck_enqueue_test.go
index 83d2121..f178601 100644
--- a/internal/worker/vulncheck_enqueue_test.go
+++ b/internal/worker/vulncheck_enqueue_test.go
@@ -23,10 +23,12 @@
 		t.Fatal(err)
 	}
 	want := &scan.Request{
-		Module:  "golang.org/x/pkgsite",
-		Version: "v0.0.0-20221004150836-873fb37c2479",
-		Suffix:  "cmd/worker",
-		Mode:    ModeBinary,
+		ModuleURLPath: scan.ModuleURLPath{
+			Module:  "golang.org/x/pkgsite",
+			Version: "v0.0.0-20221004150836-873fb37c2479",
+			Suffix:  "cmd/worker",
+		},
+		RequestParams: scan.RequestParams{Mode: ModeBinary},
 	}
 	found := false
 	for _, sr := range sreqs {
diff --git a/internal/worker/vulncheck_scan_test.go b/internal/worker/vulncheck_scan_test.go
index 686b9f1..ccd488d 100644
--- a/internal/worker/vulncheck_scan_test.go
+++ b/internal/worker/vulncheck_scan_test.go
@@ -34,7 +34,7 @@
 }
 
 func TestRunScanModule(t *testing.T) {
-	t.Skip()
+	t.Skip("breaks on trybots")
 
 	ctx := context.Background()
 	cfg, err := config.Init(ctx)