aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEthan Lee <ethanalee@google.com>2026-03-11 16:03:34 +0000
committerGopher Robot <gobot@golang.org>2026-03-18 10:11:42 -0700
commit9b7f749dab1cca49376cabf86673e35a3faff8fb (patch)
tree601fdec9c02fd5cd3dc15f4c72af61619d853f3a
parent1b7f032dee11d6534119541bc3e03f0fcbcc4995 (diff)
downloadgo-x-pkgsite-9b7f749dab1cca49376cabf86673e35a3faff8fb.tar.xz
internal/api: implement query parameter parsing for api
- Introduce structs that will be used to parse query parameters. - Implement parsing method and create relevant tests. Change-Id: Ib54a57a7eb8d2dbaab0edf705a6cb9e5bc8288a9 Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/754240 Auto-Submit: Ethan Lee <ethanalee@google.com> Reviewed-by: Jonathan Amsterdam <jba@google.com> LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> kokoro-CI: kokoro <noreply+kokoro@google.com>
-rw-r--r--internal/api/params.go192
-rw-r--r--internal/api/params_test.go184
2 files changed, 376 insertions, 0 deletions
diff --git a/internal/api/params.go b/internal/api/params.go
new file mode 100644
index 00000000..cdc5f4c5
--- /dev/null
+++ b/internal/api/params.go
@@ -0,0 +1,192 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "fmt"
+ "net/url"
+ "reflect"
+ "strconv"
+)
+
+// ListParams are common pagination and filtering parameters.
+type ListParams struct {
+ Limit int `form:"limit"`
+ Token string `form:"token"`
+ Filter string `form:"filter"`
+}
+
+// PackageParams represents query parameters for /v1/package/{path}.
+type PackageParams struct {
+ Module string `form:"module"`
+ Version string `form:"version"`
+ GOOS string `form:"goos"`
+ GOARCH string `form:"goarch"`
+ Doc string `form:"doc"`
+ Examples bool `form:"examples"`
+ Licenses bool `form:"licenses"`
+}
+
+// SymbolsParams represents query parameters for /v1/symbols/{path}.
+type SymbolsParams struct {
+ Module string `form:"module"`
+ Version string `form:"version"`
+ GOOS string `form:"goos"`
+ GOARCH string `form:"goarch"`
+ ListParams
+ Examples bool `form:"examples"`
+}
+
+// ImportedByParams represents query parameters for /v1/imported-by/{path}.
+type ImportedByParams struct {
+ Module string `form:"module"`
+ Version string `form:"version"`
+ ListParams
+}
+
+// ModuleParams represents query parameters for /v1/module/{path}.
+type ModuleParams struct {
+ Version string `form:"version"`
+ Licenses bool `form:"licenses"`
+ Readme bool `form:"readme"`
+}
+
+// VersionsParams represents query parameters for /v1/versions/{path}.
+type VersionsParams struct {
+ ListParams
+}
+
+// PackagesParams represents query parameters for /v1/packages/{path}.
+type PackagesParams struct {
+ Version string `form:"version"`
+ ListParams
+}
+
+// SearchParams represents query parameters for /v1/search.
+type SearchParams struct {
+ Query string `form:"q"`
+ Symbol string `form:"symbol"`
+ ListParams
+}
+
+// VulnParams represents query parameters for /v1/vulns/{module}.
+type VulnParams struct {
+ Version string `form:"version"`
+ ListParams
+}
+
+// ParseParams populates a struct from url.Values using 'form' tags.
+// dst must be a pointer to a struct. It supports embedded structs recursively,
+// pointers, slices, and basic types (string, int, int64, bool).
+func ParseParams(v url.Values, dst any) error {
+ val := reflect.ValueOf(dst)
+ if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct {
+ return fmt.Errorf("dst must be a pointer to a struct")
+ }
+ return parseValue(v, val.Elem())
+}
+
+func parseValue(v url.Values, val reflect.Value) error {
+ typ := val.Type()
+ for i := 0; i < val.NumField(); i++ {
+ field := val.Field(i)
+ structField := typ.Field(i)
+
+ if !field.CanSet() {
+ continue
+ }
+
+ if structField.Anonymous {
+ f := field
+ if f.Kind() == reflect.Ptr {
+ if f.IsNil() {
+ if !f.CanSet() {
+ continue
+ }
+ f.Set(reflect.New(f.Type().Elem()))
+ }
+ f = f.Elem()
+ }
+ if f.Kind() == reflect.Struct {
+ if err := parseValue(v, f); err != nil {
+ return err
+ }
+ continue
+ }
+ }
+
+ tag := structField.Tag.Get("form")
+ if tag == "" {
+ continue
+ }
+
+ if !v.Has(tag) {
+ continue
+ }
+
+ if err := setField(field, tag, v[tag]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func setField(field reflect.Value, tag string, vals []string) error {
+ if len(vals) == 0 {
+ return nil
+ }
+
+ if field.Kind() == reflect.Slice {
+ slice := reflect.MakeSlice(field.Type(), len(vals), len(vals))
+ for i, v := range vals {
+ if err := setAny(slice.Index(i), tag, v); err != nil {
+ return err
+ }
+ }
+ field.Set(slice)
+ return nil
+ }
+
+ return setAny(field, tag, vals[0])
+}
+
+func setAny(field reflect.Value, tag, val string) error {
+ if field.Kind() == reflect.Ptr {
+ if field.IsNil() {
+ field.Set(reflect.New(field.Type().Elem()))
+ }
+ return setAny(field.Elem(), tag, val)
+ }
+ return setSingle(field, tag, val)
+}
+
+func setSingle(field reflect.Value, tag, val string) error {
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString(val)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ if val == "" {
+ return fmt.Errorf("empty value for %s", tag)
+ }
+ iv, err := strconv.ParseInt(val, 10, field.Type().Bits())
+ if err != nil {
+ return fmt.Errorf("invalid value %q for %s: %w", val, tag, err)
+ }
+ field.SetInt(iv)
+ case reflect.Bool:
+ if val == "" {
+ field.SetBool(false)
+ return nil
+ }
+ bv, err := strconv.ParseBool(val)
+ if err != nil {
+ return fmt.Errorf("invalid boolean value %q for %s: %w", val, tag, err)
+ }
+ field.SetBool(bv)
+ default:
+ return fmt.Errorf("unsupported type %s for field %s", field.Type(), tag)
+ }
+ return nil
+}
diff --git a/internal/api/params_test.go b/internal/api/params_test.go
new file mode 100644
index 00000000..6d325247
--- /dev/null
+++ b/internal/api/params_test.go
@@ -0,0 +1,184 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package api
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestParseParams(t *testing.T) {
+ type Nested struct {
+ ListParams
+ }
+ type DeepNested struct {
+ Nested
+ }
+ type EmbeddedPtr struct {
+ *ListParams
+ }
+ type extraParams struct {
+ Int64 int64 `form:"i64"`
+ Slice []string `form:"slice"`
+ PtrInt *int `form:"ptr"`
+ PtrPtr **int `form:"ptrptr"`
+ PtrSlice []*string `form:"ptrslice"`
+ private int `form:"private"` // Should be ignored
+ }
+ type overflowParams struct {
+ Int8 int8 `form:"i8"`
+ }
+ type unsupportedParams struct {
+ Float float64 `form:"float"`
+ }
+
+ for _, test := range []struct {
+ name string
+ values url.Values
+ dst any
+ want any
+ wantErr bool
+ }{
+ {
+ name: "PackageParams",
+ values: url.Values{"module": {"m"}, "version": {"v1.0.0"}, "goos": {"linux"}, "examples": {"true"}},
+ dst: &PackageParams{},
+ want: &PackageParams{
+ Module: "m",
+ Version: "v1.0.0",
+ GOOS: "linux",
+ Examples: true,
+ },
+ },
+ {
+ name: "Boolean presence",
+ values: url.Values{"examples": {"true"}, "licenses": {"1"}},
+ dst: &PackageParams{},
+ want: &PackageParams{
+ Examples: true,
+ Licenses: true,
+ },
+ },
+ {
+ name: "Boolean presence (ModuleParams)",
+ values: url.Values{"licenses": {"0"}, "readme": {"false"}},
+ dst: &ModuleParams{},
+ want: &ModuleParams{
+ Licenses: false,
+ Readme: false,
+ },
+ },
+ {
+ name: "Empty bool",
+ values: url.Values{"examples": {""}},
+ dst: &PackageParams{},
+ want: &PackageParams{
+ Examples: false,
+ },
+ },
+ {
+ name: "Invalid bool (on)",
+ values: url.Values{"examples": {"on"}},
+ dst: &PackageParams{},
+ wantErr: true,
+ },
+ {
+ name: "Deeply nested embedding",
+ values: url.Values{"limit": {"100"}},
+ dst: &DeepNested{},
+ want: &DeepNested{
+ Nested: Nested{
+ ListParams: ListParams{Limit: 100},
+ },
+ },
+ },
+ {
+ name: "Extra types (int64, slice, ptr, ptrptr, ptrslice)",
+ values: url.Values{
+ "i64": {"9223372036854775807"},
+ "slice": {"a", "b"},
+ "ptr": {"42"},
+ "ptrptr": {"84"},
+ "ptrslice": {"one", "two"},
+ "private": {"1"},
+ },
+ dst: &extraParams{},
+ want: &extraParams{
+ Int64: 9223372036854775807,
+ Slice: []string{"a", "b"},
+ PtrInt: intPtr(42),
+ PtrPtr: intPtrPtr(84),
+ PtrSlice: []*string{stringPtr("one"), stringPtr("two")},
+ private: 0, // Ignored
+ },
+ },
+ {
+ name: "Malformed int",
+ values: url.Values{"limit": {"10.5"}},
+ dst: &SymbolsParams{},
+ wantErr: true,
+ },
+ {
+ name: "Malformed bool",
+ values: url.Values{"examples": {"maybe"}},
+ dst: &PackageParams{},
+ wantErr: true,
+ },
+ {
+ name: "Empty int",
+ values: url.Values{"limit": {""}},
+ dst: &SymbolsParams{},
+ wantErr: true,
+ },
+ {
+ name: "Not a pointer",
+ values: url.Values{},
+ dst: PackageParams{},
+ wantErr: true,
+ },
+ {
+ name: "Int8 overflow",
+ values: url.Values{"i8": {"128"}},
+ dst: &overflowParams{},
+ wantErr: true,
+ },
+ {
+ name: "Unsupported type",
+ values: url.Values{"float": {"1.2"}},
+ dst: &unsupportedParams{},
+ wantErr: true,
+ },
+ {
+ name: "Embedded pointer",
+ values: url.Values{"limit": {"50"}},
+ dst: &EmbeddedPtr{},
+ want: &EmbeddedPtr{
+ ListParams: &ListParams{Limit: 50},
+ },
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ err := ParseParams(test.values, test.dst)
+ if (err != nil) != test.wantErr {
+ t.Fatalf("ParseParams() error = %v, wantErr %v", err, test.wantErr)
+ }
+ if test.wantErr {
+ return
+ }
+ if diff := cmp.Diff(test.want, test.dst, cmp.AllowUnexported(extraParams{})); diff != "" {
+ t.Errorf("ParseParams() mismatch (-want +got):\n%s", diff)
+ }
+ })
+ }
+}
+
+func intPtr(i int) *int { return &i }
+func intPtrPtr(i int) **int {
+ p := intPtr(i)
+ return &p
+}
+func stringPtr(s string) *string { return &s }