diff options
| author | Ethan Lee <ethanalee@google.com> | 2026-03-11 16:03:34 +0000 |
|---|---|---|
| committer | Gopher Robot <gobot@golang.org> | 2026-03-18 10:11:42 -0700 |
| commit | 9b7f749dab1cca49376cabf86673e35a3faff8fb (patch) | |
| tree | 601fdec9c02fd5cd3dc15f4c72af61619d853f3a | |
| parent | 1b7f032dee11d6534119541bc3e03f0fcbcc4995 (diff) | |
| download | go-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.go | 192 | ||||
| -rw-r--r-- | internal/api/params_test.go | 184 |
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 } |
