Skip to content

Commit 1467a92

Browse files
authored
feature(http): add signature and param package (#28)
* feature(http): add signature and param package - allow handler functions with nicer signatures - allow parsing path and query params to struct * lint * feature: finish awesome param and signature packages * chore: changelog * ci: up lint version * feature: change default tags of param package to `param:"location=name"` * tests * refactor: reduce if nesting * chore: changelog * fix: changelog
1 parent 78018b3 commit 1467a92

File tree

12 files changed

+1436
-5
lines changed

12 files changed

+1436
-5
lines changed

.github/actions/setup-go/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: |
55
inputs:
66
go-version:
77
description: Used Go version
8-
default: '1.19'
8+
default: '1.20'
99

1010
runs:
1111
using: "composite"

.github/workflows/lint.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ jobs:
2323
- name: Run golangci-lint
2424
uses: golangci/golangci-lint-action@v3
2525
with:
26-
version: v1.50.1
26+
version: v1.51.1

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ How to release a new version:
55

66
## [Unreleased]
77

8+
## [0.6.0] - 2023-03-03
9+
### Added
10+
- package `http/signature` to simplify defining http handler functions
11+
- package `http/param` to simplify parsing http path and query parameters
12+
813
## [0.5.0] - 2022-01-20
914
### Added
1015
- `ErrorResponseOptions` contains public error message.
@@ -40,7 +45,8 @@ How to release a new version:
4045
### Added
4146
- Added Changelog.
4247

43-
[Unreleased]: https://github.com/strvcom/strv-backend-go-net/compare/v0.5.0...HEAD
48+
[Unreleased]: https://github.com/strvcom/strv-backend-go-net/compare/v0.6.0...HEAD
49+
[0.6.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.5.0...v0.6.0
4450
[0.5.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.4.0...v0.5.0
4551
[0.4.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.3.0...v0.4.0
4652
[0.3.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.2.0...v0.3.0

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
module go.strv.io/net
22

3-
go 1.19
3+
go 1.20
44

55
require (
6+
github.com/go-chi/chi/v5 v5.0.8
67
github.com/google/uuid v1.3.0
78
github.com/stretchr/testify v1.8.0
89
go.strv.io/time v0.2.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
22
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
6+
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
57
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
68
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
79
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=

http/encode.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func WithEncodeFunc(fn EncodeFunc) ResponseOption {
2020
}
2121
}
2222

23-
// DecodeJSON decodes data using JSON marshalling into the type of parameter v.
23+
// DecodeJSON decodes data using JSON marshaling into the type of parameter v.
2424
func DecodeJSON(data any, v any) error {
2525
b, err := json.Marshal(data)
2626
if err != nil {

http/param/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Package for parsing path and query parameters from http request into struct, similar to parsing body as json to struct.
2+
3+
```
4+
type MyInputStruct struct {
5+
UserID int `param:"path=id"`
6+
SomeFlag *bool `param:"query=flag"`
7+
}
8+
```
9+
10+
Then a request like `http://somewhere.com/users/9?flag=true` can be parsed as follows.
11+
In this example, using chi to access path parameters that has a `{id}` wildcard in configured chi router
12+
13+
```
14+
parsedInput := MyInputStruct{}
15+
param.DefaultParser().PathParamFunc(chi.URLParam).Parse(request, &parsedInput)
16+
```

http/param/param.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package param
2+
3+
import (
4+
"encoding"
5+
"fmt"
6+
"net/http"
7+
"reflect"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// TagResolver is a function that decides from a field type what key of http parameter should be searched.
13+
// Second return value should return whether the key should be searched in http parameter at all.
14+
type TagResolver func(fieldTag reflect.StructTag) (string, bool)
15+
16+
// FixedTagNameParamTagResolver returns a TagResolver, that matches struct params by specific tag.
17+
// Example: FixedTagNameParamTagResolver("mytag") matches a field tagged with `mytag:"param_name"`
18+
func FixedTagNameParamTagResolver(tagName string) TagResolver {
19+
return func(fieldTag reflect.StructTag) (string, bool) {
20+
taggedParamName := fieldTag.Get(tagName)
21+
return taggedParamName, taggedParamName != ""
22+
}
23+
}
24+
25+
// TagWithModifierTagResolver returns a TagResolver, that matches struct params by specific tag and
26+
// by a value before a '=' separator.
27+
// Example: FixedTagNameParamTagResolver("mytag", "mymodifier") matches a field tagged with `mytag:"mymodifier=param_name"`
28+
func TagWithModifierTagResolver(tagName string, tagModifier string) TagResolver {
29+
return func(fieldTag reflect.StructTag) (string, bool) {
30+
tagValue := fieldTag.Get(tagName)
31+
if tagValue == "" {
32+
return "", false
33+
}
34+
splits := strings.Split(tagValue, "=")
35+
//nolint:gomnd // 2 not really that magic number - one value before '=', one after
36+
if len(splits) != 2 {
37+
return "", false
38+
}
39+
if splits[0] == tagModifier {
40+
return splits[1], true
41+
}
42+
return "", false
43+
}
44+
}
45+
46+
// PathParamFunc is a function that returns value of specified http path parameter
47+
type PathParamFunc func(r *http.Request, key string) string
48+
49+
// Parser can Parse query and path parameters from http.Request into a struct.
50+
// Fields struct have to be tagged such that either QueryParamTagResolver or PathParamTagResolver returns
51+
// valid parameter name from the provided tag.
52+
//
53+
// PathParamFunc is for getting path parameter from http.Request, as each http router handles it in different way (if at all).
54+
// For example for chi, use WithPathParamFunc(chi.URLParam) to be able to use tags for path parameters.
55+
type Parser struct {
56+
QueryParamTagResolver TagResolver
57+
PathParamTagResolver TagResolver
58+
PathParamFunc PathParamFunc
59+
}
60+
61+
// DefaultParser returns query and path parameter Parser with intended struct tags
62+
// `param:"query=param_name"` for query parameters and `param:"path=param_name"` for path parameters
63+
func DefaultParser() Parser {
64+
return Parser{
65+
QueryParamTagResolver: TagWithModifierTagResolver("param", "query"),
66+
PathParamTagResolver: TagWithModifierTagResolver("param", "path"),
67+
PathParamFunc: nil, // keep nil, as there is no sensible default of how to get value of path parameter
68+
}
69+
}
70+
71+
// WithPathParamFunc returns a copy of Parser with set function for getting path parameters from http.Request.
72+
// For more see Parser description.
73+
func (p Parser) WithPathParamFunc(f PathParamFunc) Parser {
74+
p.PathParamFunc = f
75+
return p
76+
}
77+
78+
// Parse accepts the request and a pointer to struct that is tagged with appropriate tags set in Parser.
79+
// All such tagged fields are assigned the respective parameter from the actual request.
80+
//
81+
// Fields are assigned their zero value if the field was tagged but request did not contain such parameter.
82+
//
83+
// Supported tagged field types are:
84+
// - primitive types - bool, all ints, all uints, both floats, and string
85+
// - pointer to any supported type
86+
// - slice of non-slice supported type (only for query parameters)
87+
// - any type that implements encoding.TextUnmarshaler
88+
//
89+
// For query parameters, the tagged type can be a slice. This means that a query like /endpoint?key=val1&key=val2
90+
// is allowed, and in such case the slice field will be assigned []T{"val1", "val2"} .
91+
// Otherwise, only single query parameter is allowed in request.
92+
func (p Parser) Parse(r *http.Request, dest any) error {
93+
v := reflect.ValueOf(dest)
94+
if v.Kind() != reflect.Pointer {
95+
return fmt.Errorf("cannot set non-pointer value of type %s", v.Type().Name())
96+
}
97+
v = v.Elem()
98+
99+
if v.Kind() != reflect.Struct {
100+
return fmt.Errorf("can only parse into struct, but got %s", v.Type().Name())
101+
}
102+
103+
for i := 0; i < v.NumField(); i++ {
104+
typeField := v.Type().Field(i)
105+
if !typeField.IsExported() {
106+
continue
107+
}
108+
valueField := v.Field(i)
109+
// Zero the value, even if it would not be set by following path or query parameter.
110+
// This will cause potential partial result from previous parser (e.g. json.Unmarshal) to be discarded on
111+
// fields that are tagged for path or query parameter.
112+
valueField.Set(reflect.Zero(typeField.Type))
113+
tag := typeField.Tag
114+
err := p.parseQueryParam(r, tag, valueField)
115+
if err != nil {
116+
return err
117+
}
118+
err = p.parsePathParam(r, tag, valueField)
119+
if err != nil {
120+
return err
121+
}
122+
}
123+
return nil
124+
}
125+
126+
func (p Parser) parsePathParam(r *http.Request, tag reflect.StructTag, v reflect.Value) error {
127+
paramName, ok := p.PathParamTagResolver(tag)
128+
if !ok {
129+
return nil
130+
}
131+
if p.PathParamFunc == nil {
132+
return fmt.Errorf("struct's field was tagged for parsing the path parameter (%s) but PathParamFunc to get value of path parameter is not defined", paramName)
133+
}
134+
paramValue := p.PathParamFunc(r, paramName)
135+
if paramValue != "" {
136+
err := unmarshalValue(paramValue, v)
137+
if err != nil {
138+
return fmt.Errorf("unmarshaling path parameter %s: %w", paramName, err)
139+
}
140+
}
141+
return nil
142+
}
143+
144+
func (p Parser) parseQueryParam(r *http.Request, tag reflect.StructTag, v reflect.Value) error {
145+
paramName, ok := p.QueryParamTagResolver(tag)
146+
if !ok {
147+
return nil
148+
}
149+
query := r.URL.Query()
150+
if values, ok := query[paramName]; ok && len(values) > 0 {
151+
err := unmarshalValueOrSlice(values, v)
152+
if err != nil {
153+
return fmt.Errorf("unmarshaling query parameter %s: %w", paramName, err)
154+
}
155+
}
156+
return nil
157+
}
158+
159+
func unmarshalValueOrSlice(texts []string, dest reflect.Value) error {
160+
if unmarshaler, ok := dest.Addr().Interface().(encoding.TextUnmarshaler); ok {
161+
if len(texts) != 1 {
162+
return fmt.Errorf("too many parameters unmarshaling to %s, expected up to 1 value", dest.Type().Name())
163+
}
164+
return unmarshaler.UnmarshalText([]byte(texts[0]))
165+
}
166+
t := dest.Type()
167+
if t.Kind() == reflect.Pointer {
168+
ptrValue := reflect.New(t.Elem())
169+
dest.Set(ptrValue)
170+
return unmarshalValueOrSlice(texts, dest.Elem())
171+
}
172+
if t.Kind() == reflect.Slice {
173+
sliceValue := reflect.MakeSlice(t, len(texts), len(texts))
174+
for i, text := range texts {
175+
if err := unmarshalValue(text, sliceValue.Index(i)); err != nil {
176+
return fmt.Errorf("unmarshaling %dth element: %w", i, err)
177+
}
178+
}
179+
dest.Set(sliceValue)
180+
return nil
181+
}
182+
if len(texts) != 1 {
183+
return fmt.Errorf("too many parameters unmarshaling to %s, expected up to 1 value", dest.Type().Name())
184+
}
185+
return unmarshalPrimitiveValue(texts[0], dest)
186+
}
187+
188+
func unmarshalValue(text string, dest reflect.Value) error {
189+
if unmarshaler, ok := dest.Addr().Interface().(encoding.TextUnmarshaler); ok {
190+
return unmarshaler.UnmarshalText([]byte(text))
191+
}
192+
t := dest.Type()
193+
if t.Kind() == reflect.Pointer {
194+
ptrValue := reflect.New(t.Elem())
195+
dest.Set(ptrValue)
196+
return unmarshalValue(text, dest.Elem())
197+
}
198+
return unmarshalPrimitiveValue(text, dest)
199+
}
200+
201+
func unmarshalPrimitiveValue(text string, dest reflect.Value) error {
202+
//nolint:exhaustive
203+
switch dest.Kind() {
204+
case reflect.Bool:
205+
v, err := strconv.ParseBool(text)
206+
if err != nil {
207+
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
208+
}
209+
dest.SetBool(v)
210+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
211+
v, err := strconv.ParseInt(text, 10, dest.Type().Bits())
212+
if err != nil {
213+
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
214+
}
215+
dest.SetInt(v)
216+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
217+
v, err := strconv.ParseUint(text, 10, dest.Type().Bits())
218+
if err != nil {
219+
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
220+
}
221+
dest.SetUint(v)
222+
case reflect.Float32, reflect.Float64:
223+
v, err := strconv.ParseFloat(text, dest.Type().Bits())
224+
if err != nil {
225+
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
226+
}
227+
dest.SetFloat(v)
228+
case reflect.String:
229+
dest.SetString(text)
230+
default:
231+
return fmt.Errorf("unsupported field type %s", dest.Type().Name())
232+
}
233+
return nil
234+
}

0 commit comments

Comments
 (0)