Skip to content

Commit

Permalink
feature(http): add signature and param package (#28)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Fazt01 authored Mar 3, 2023
1 parent 78018b3 commit 1467a92
Show file tree
Hide file tree
Showing 12 changed files with 1,436 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .github/actions/setup-go/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: |
inputs:
go-version:
description: Used Go version
default: '1.19'
default: '1.20'

runs:
using: "composite"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ jobs:
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.50.1
version: v1.51.1
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ How to release a new version:

## [Unreleased]

## [0.6.0] - 2023-03-03
### Added
- package `http/signature` to simplify defining http handler functions
- package `http/param` to simplify parsing http path and query parameters

## [0.5.0] - 2022-01-20
### Added
- `ErrorResponseOptions` contains public error message.
Expand Down Expand Up @@ -40,7 +45,8 @@ How to release a new version:
### Added
- Added Changelog.

[Unreleased]: https://github.com/strvcom/strv-backend-go-net/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/strvcom/strv-backend-go-net/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/strvcom/strv-backend-go-net/compare/v0.2.0...v0.3.0
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
module go.strv.io/net

go 1.19
go 1.20

require (
github.com/go-chi/chi/v5 v5.0.8
github.com/google/uuid v1.3.0
github.com/stretchr/testify v1.8.0
go.strv.io/time v0.2.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
Expand Down
2 changes: 1 addition & 1 deletion http/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func WithEncodeFunc(fn EncodeFunc) ResponseOption {
}
}

// DecodeJSON decodes data using JSON marshalling into the type of parameter v.
// DecodeJSON decodes data using JSON marshaling into the type of parameter v.
func DecodeJSON(data any, v any) error {
b, err := json.Marshal(data)
if err != nil {
Expand Down
16 changes: 16 additions & 0 deletions http/param/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Package for parsing path and query parameters from http request into struct, similar to parsing body as json to struct.

```
type MyInputStruct struct {
UserID int `param:"path=id"`
SomeFlag *bool `param:"query=flag"`
}
```

Then a request like `http://somewhere.com/users/9?flag=true` can be parsed as follows.
In this example, using chi to access path parameters that has a `{id}` wildcard in configured chi router

```
parsedInput := MyInputStruct{}
param.DefaultParser().PathParamFunc(chi.URLParam).Parse(request, &parsedInput)
```
234 changes: 234 additions & 0 deletions http/param/param.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package param

import (
"encoding"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
)

// TagResolver is a function that decides from a field type what key of http parameter should be searched.
// Second return value should return whether the key should be searched in http parameter at all.
type TagResolver func(fieldTag reflect.StructTag) (string, bool)

// FixedTagNameParamTagResolver returns a TagResolver, that matches struct params by specific tag.
// Example: FixedTagNameParamTagResolver("mytag") matches a field tagged with `mytag:"param_name"`
func FixedTagNameParamTagResolver(tagName string) TagResolver {
return func(fieldTag reflect.StructTag) (string, bool) {
taggedParamName := fieldTag.Get(tagName)
return taggedParamName, taggedParamName != ""
}
}

// TagWithModifierTagResolver returns a TagResolver, that matches struct params by specific tag and
// by a value before a '=' separator.
// Example: FixedTagNameParamTagResolver("mytag", "mymodifier") matches a field tagged with `mytag:"mymodifier=param_name"`
func TagWithModifierTagResolver(tagName string, tagModifier string) TagResolver {
return func(fieldTag reflect.StructTag) (string, bool) {
tagValue := fieldTag.Get(tagName)
if tagValue == "" {
return "", false
}
splits := strings.Split(tagValue, "=")
//nolint:gomnd // 2 not really that magic number - one value before '=', one after
if len(splits) != 2 {
return "", false
}
if splits[0] == tagModifier {
return splits[1], true
}
return "", false
}
}

// PathParamFunc is a function that returns value of specified http path parameter
type PathParamFunc func(r *http.Request, key string) string

// Parser can Parse query and path parameters from http.Request into a struct.
// Fields struct have to be tagged such that either QueryParamTagResolver or PathParamTagResolver returns
// valid parameter name from the provided tag.
//
// PathParamFunc is for getting path parameter from http.Request, as each http router handles it in different way (if at all).
// For example for chi, use WithPathParamFunc(chi.URLParam) to be able to use tags for path parameters.
type Parser struct {
QueryParamTagResolver TagResolver
PathParamTagResolver TagResolver
PathParamFunc PathParamFunc
}

// DefaultParser returns query and path parameter Parser with intended struct tags
// `param:"query=param_name"` for query parameters and `param:"path=param_name"` for path parameters
func DefaultParser() Parser {
return Parser{
QueryParamTagResolver: TagWithModifierTagResolver("param", "query"),
PathParamTagResolver: TagWithModifierTagResolver("param", "path"),
PathParamFunc: nil, // keep nil, as there is no sensible default of how to get value of path parameter
}
}

// WithPathParamFunc returns a copy of Parser with set function for getting path parameters from http.Request.
// For more see Parser description.
func (p Parser) WithPathParamFunc(f PathParamFunc) Parser {
p.PathParamFunc = f
return p
}

// Parse accepts the request and a pointer to struct that is tagged with appropriate tags set in Parser.
// All such tagged fields are assigned the respective parameter from the actual request.
//
// Fields are assigned their zero value if the field was tagged but request did not contain such parameter.
//
// Supported tagged field types are:
// - primitive types - bool, all ints, all uints, both floats, and string
// - pointer to any supported type
// - slice of non-slice supported type (only for query parameters)
// - any type that implements encoding.TextUnmarshaler
//
// For query parameters, the tagged type can be a slice. This means that a query like /endpoint?key=val1&key=val2
// is allowed, and in such case the slice field will be assigned []T{"val1", "val2"} .
// Otherwise, only single query parameter is allowed in request.
func (p Parser) Parse(r *http.Request, dest any) error {
v := reflect.ValueOf(dest)
if v.Kind() != reflect.Pointer {
return fmt.Errorf("cannot set non-pointer value of type %s", v.Type().Name())
}
v = v.Elem()

if v.Kind() != reflect.Struct {
return fmt.Errorf("can only parse into struct, but got %s", v.Type().Name())
}

for i := 0; i < v.NumField(); i++ {
typeField := v.Type().Field(i)
if !typeField.IsExported() {
continue
}
valueField := v.Field(i)
// Zero the value, even if it would not be set by following path or query parameter.
// This will cause potential partial result from previous parser (e.g. json.Unmarshal) to be discarded on
// fields that are tagged for path or query parameter.
valueField.Set(reflect.Zero(typeField.Type))
tag := typeField.Tag
err := p.parseQueryParam(r, tag, valueField)
if err != nil {
return err
}
err = p.parsePathParam(r, tag, valueField)
if err != nil {
return err
}
}
return nil
}

func (p Parser) parsePathParam(r *http.Request, tag reflect.StructTag, v reflect.Value) error {
paramName, ok := p.PathParamTagResolver(tag)
if !ok {
return nil
}
if p.PathParamFunc == nil {
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)
}
paramValue := p.PathParamFunc(r, paramName)
if paramValue != "" {
err := unmarshalValue(paramValue, v)
if err != nil {
return fmt.Errorf("unmarshaling path parameter %s: %w", paramName, err)
}
}
return nil
}

func (p Parser) parseQueryParam(r *http.Request, tag reflect.StructTag, v reflect.Value) error {
paramName, ok := p.QueryParamTagResolver(tag)
if !ok {
return nil
}
query := r.URL.Query()
if values, ok := query[paramName]; ok && len(values) > 0 {
err := unmarshalValueOrSlice(values, v)
if err != nil {
return fmt.Errorf("unmarshaling query parameter %s: %w", paramName, err)
}
}
return nil
}

func unmarshalValueOrSlice(texts []string, dest reflect.Value) error {
if unmarshaler, ok := dest.Addr().Interface().(encoding.TextUnmarshaler); ok {
if len(texts) != 1 {
return fmt.Errorf("too many parameters unmarshaling to %s, expected up to 1 value", dest.Type().Name())
}
return unmarshaler.UnmarshalText([]byte(texts[0]))
}
t := dest.Type()
if t.Kind() == reflect.Pointer {
ptrValue := reflect.New(t.Elem())
dest.Set(ptrValue)
return unmarshalValueOrSlice(texts, dest.Elem())
}
if t.Kind() == reflect.Slice {
sliceValue := reflect.MakeSlice(t, len(texts), len(texts))
for i, text := range texts {
if err := unmarshalValue(text, sliceValue.Index(i)); err != nil {
return fmt.Errorf("unmarshaling %dth element: %w", i, err)
}
}
dest.Set(sliceValue)
return nil
}
if len(texts) != 1 {
return fmt.Errorf("too many parameters unmarshaling to %s, expected up to 1 value", dest.Type().Name())
}
return unmarshalPrimitiveValue(texts[0], dest)
}

func unmarshalValue(text string, dest reflect.Value) error {
if unmarshaler, ok := dest.Addr().Interface().(encoding.TextUnmarshaler); ok {
return unmarshaler.UnmarshalText([]byte(text))
}
t := dest.Type()
if t.Kind() == reflect.Pointer {
ptrValue := reflect.New(t.Elem())
dest.Set(ptrValue)
return unmarshalValue(text, dest.Elem())
}
return unmarshalPrimitiveValue(text, dest)
}

func unmarshalPrimitiveValue(text string, dest reflect.Value) error {
//nolint:exhaustive
switch dest.Kind() {
case reflect.Bool:
v, err := strconv.ParseBool(text)
if err != nil {
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
}
dest.SetBool(v)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
v, err := strconv.ParseInt(text, 10, dest.Type().Bits())
if err != nil {
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
}
dest.SetInt(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := strconv.ParseUint(text, 10, dest.Type().Bits())
if err != nil {
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
}
dest.SetUint(v)
case reflect.Float32, reflect.Float64:
v, err := strconv.ParseFloat(text, dest.Type().Bits())
if err != nil {
return fmt.Errorf("parsing into field of type %s: %w", dest.Type().Name(), err)
}
dest.SetFloat(v)
case reflect.String:
dest.SetString(text)
default:
return fmt.Errorf("unsupported field type %s", dest.Type().Name())
}
return nil
}
Loading

0 comments on commit 1467a92

Please sign in to comment.