Skip to content

Commit

Permalink
refactor: kcd
Browse files Browse the repository at this point in the history
  • Loading branch information
alexisvisco committed Jul 22, 2020
1 parent 4d62ab9 commit 5ac8051
Show file tree
Hide file tree
Showing 17 changed files with 349 additions and 365 deletions.
25 changes: 11 additions & 14 deletions pkg/kcd/binding.go → binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@ import (
"reflect"
"strconv"
"time"

"github.com/expectedsh/kcd/pkg/extractor"
"github.com/expectedsh/kcd/pkg/kcderr"
)

// bind binds the fields the fields of the input object in with
// the values of the parameters extracted from the Gin context.
// It reads tag to know what to extract using the extractor func.
func bind(w http.ResponseWriter, r *http.Request, v reflect.Value, tag string, extract extractor.Extractor) error {
func bind(w http.ResponseWriter, r *http.Request, v reflect.Value, tag string, extract Extractor) error {
t := v.Type()

if t.Kind() == reflect.Ptr {
Expand Down Expand Up @@ -55,18 +52,18 @@ func bind(w http.ResponseWriter, r *http.Request, v reflect.Value, tag string, e
continue
}

bindingError := &kcderr.Input{Field: tagValue, Type: t, Extractor: tag}
bindingError := &inputError{field: tagValue, fieldType: t, extractor: tag}

fieldValues, err := extract(w, r, tagValue)
if err != nil {
return bindingError.
WithErr(err).
WithMessage("unable to extract value from request")
withErr(err).
withMessage("unable to extract value from request")
}

// Extract default value and use it in place
// if no values were returned.
def, ok := ft.Tag.Lookup(Config.DefaultTag)
def, ok := ft.Tag.Lookup(defaultTag)
if ok && len(fieldValues) == 0 {
fieldValues = append(fieldValues, def)
}
Expand All @@ -89,14 +86,14 @@ func bind(w http.ResponseWriter, r *http.Request, v reflect.Value, tag string, e

// Multiple values can only be filled to types Slice and Array.
if len(fieldValues) > 1 && (kind != reflect.Slice && kind != reflect.Array) {
return bindingError.WithMessage("multiple values not supported")
return bindingError.withMessage("multiple values not supported")
}

// Ensure that the number of values to fill does not exceed the length of a field of type Array.
if kind == reflect.Array {
if field.Len() != len(fieldValues) {
msg := fmt.Sprintf("parameter expect %d values, got %d", field.Len(), len(fieldValues))
return bindingError.WithMessage(msg)
return bindingError.withMessage(msg)
}
}

Expand All @@ -111,8 +108,8 @@ func bind(w http.ResponseWriter, r *http.Request, v reflect.Value, tag string, e
err = bindStringValue(val, v)
if err != nil {
return bindingError.
WithErr(err).
WithMessage(fmt.Sprintf("unable to set the value %q as type %+v", val, v.Type().Name()))
withErr(err).
withMessage(fmt.Sprintf("unable to set the value %q as type %+v", val, v.Type().Name()))
}
if kind == reflect.Slice {
field.Set(reflect.Append(field, v))
Expand All @@ -128,8 +125,8 @@ func bind(w http.ResponseWriter, r *http.Request, v reflect.Value, tag string, e
err = bindStringValue(fieldValues[0], field)
if err != nil {
return bindingError.
WithErr(err).
WithMessage(fmt.Sprintf("unable to set the value %q as type %+v", fieldValues[0], field.Type().Name()))
withErr(err).
withMessage(fmt.Sprintf("unable to set the value %q as type %+v", fieldValues[0], field.Type().Name()))
}
}

Expand Down
46 changes: 46 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kcd

import (
"fmt"
"reflect"
)

type inputError struct {
message string
fieldType reflect.Type
field string
extractor string
err error
}

func (b *inputError) withMessage(msg string) *inputError {
b.message = msg
return b
}

func (b *inputError) withErr(err error) *inputError {
b.err = err
return b
}

// ErrorDescription implements the builtin error interface for inputError.
func (b inputError) Error() string {
if b.field != "" && b.fieldType != nil {
return fmt.Sprintf(
"binding error on field '%s' of type '%s': %s",
b.field,
b.fieldType.Name(),
b.message,
)
}
return fmt.Sprintf("binding error: %s", b.message)
}

type outputError struct {
Err error
}

// ErrorDescription implements the builtin error interface for outputError.
func (o outputError) Error() string {
return "unable to marshal response into json format"
}
4 changes: 2 additions & 2 deletions examples/simple/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"

"github.com/expectedsh/kcd/pkg/kcd"
"github.com/expectedsh/kcd"
)

func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)

// kcd.Config.BindingHook = ...
// kcd.Config.BindHook = ...

r.Post("/{path}", kcd.Handler(CreateCustomer, http.StatusOK))

Expand Down
41 changes: 41 additions & 0 deletions extractors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package kcd

import (
"net/http"
"strings"

"github.com/go-chi/chi"
)

// defaultHeaderExtractor is an extractor that operates on the headers
// of a request.
func defaultHeaderExtractor(_ http.ResponseWriter, r *http.Request, tag string) ([]string, error) {
header := r.Header.Get(tag)

return []string{header}, nil
}

// defaultQueryExtractor is an extractor that operates on the path
// parameters of a request.
func defaultQueryExtractor(_ http.ResponseWriter, r *http.Request, tag string) ([]string, error) {
var params []string
query := r.URL.Query()[tag]

splitFn := func(c rune) bool {
return c == ','
}

for _, q := range query {
params = append(params, strings.FieldsFunc(q, splitFn)...)
}

return params, nil
}

// defaultPathExtractor is an extractor that operates on the path
// parameters of a request.
func defaultPathExtractor(_ http.ResponseWriter, r *http.Request, tag string) ([]string, error) {
p := chi.URLParam(r, tag)

return []string{p}, nil
}
10 changes: 5 additions & 5 deletions pkg/kcd/handler.go → handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const (
//
// func([w http.ResponseWriter], [r *http.Request], [input object ptr]) ([output object], error)
//
// Input and output objects are both optional.
// inputError and output objects are both optional.
// As such, the minimal accepted signature is:
//
// func(w http.ResponseWriter, r *http.Request) error
Expand Down Expand Up @@ -55,25 +55,25 @@ func Handler(h interface{}, defaultSuccessStatusCode int) http.HandlerFunc {
input = &i

// Bind body
if err := Config.BindingHook(w, r, input.Interface()); err != nil {
if err := Config.BindHook(w, r, input.Interface()); err != nil {
Config.ErrorHook(w, r, err)
return
}

// Bind query-parameters.
if err := bind(w, r, i, Config.QueryTag, Config.QueryExtractor); err != nil {
if err := bind(w, r, i, queryTag, Config.QueryExtractor); err != nil {
Config.ErrorHook(w, r, err)
return
}

// Bind path arguments.
if err := bind(w, r, i, Config.PathTag, Config.PathExtractor); err != nil {
if err := bind(w, r, i, pathTag, Config.PathExtractor); err != nil {
Config.ErrorHook(w, r, err)
return
}

// Bind headers.
if err := bind(w, r, i, Config.HeaderTag, Config.HeaderExtractor); err != nil {
if err := bind(w, r, i, headerTag, Config.HeaderExtractor); err != nil {
Config.ErrorHook(w, r, err)
return
}
Expand Down
160 changes: 160 additions & 0 deletions hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package kcd

import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"

"github.com/expectedsh/errors"
"github.com/go-chi/chi/middleware"
validation "github.com/go-ozzo/ozzo-validation/v4"
)

// defaultValidateHook is the default validation hook.
// It use 'ozzo-validation' to validate structure.
// A structure must implement 'ValidatableWithContext' or 'Validatable'
func defaultValidateHook(ctx context.Context, input interface{}) error {
var err validation.Errors

switch v := input.(type) {
case validation.ValidatableWithContext:
err = v.ValidateWithContext(ctx).(validation.Errors)
case validation.Validatable:
err = v.Validate().(validation.Errors)
}

if len(err) == 0 {
return nil
}

return errors.
NewWithKind(errors.KindInvalidArgument, "the request has one or multiple invalid fields").
WithMetadata("kcd.fields", err)
}

// defaultRenderHook is the default render hook.
// It marshals the payload to JSON, or returns an empty body if the payload is nil.
func defaultRenderHook(w http.ResponseWriter, _ *http.Request, statusCode int, response interface{}) error {
if response != nil {
marshal, err := json.Marshal(response)
if err != nil {
return outputError{Err: err}
}

w.WriteHeader(statusCode)
if _, err := w.Write(marshal); err != nil {
return err
}

} else {
w.WriteHeader(statusCode)
}

return nil
}

// errorResponse is the default response that send the default error hook
type errorResponse struct {
ErrorDescription string `json:"error_description"`
Error errors.Kind `json:"error"`

Fields map[string]string `json:"fields,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}

// defaultErrorHook is the default error hook.
// It check the error and return the corresponding response to the client.
func defaultErrorHook(w http.ResponseWriter, r *http.Request, err error) {

response := errorResponse{
ErrorDescription: "internal server error",
Fields: map[string]string{},
Metadata: map[string]interface{}{},
}

reqID := middleware.GetReqID(r.Context())
if reqID != "" {
response.Metadata["request_id"] = reqID
}

switch e := err.(type) {
case *errors.Error:
w.WriteHeader(e.Kind.ToStatusCode())

response.ErrorDescription = e.Message
response.Error = e.Kind

// todo: don't use string literal for kcd.*

metadata, ok := e.GetMetadata(errorKeyFields)
if ok {
m, okMap := metadata.(validation.Errors)
if okMap {
for k, v := range m {
response.Fields[k] = v.Error()
}
}
}

metadata, ok = e.GetMetadata(errorKeyMetadata)
if ok {
m, okMap := metadata.(map[string]interface{})
if okMap {
for k, v := range m {
response.Metadata[k] = v
}
}
}

case inputError:
w.WriteHeader(http.StatusBadRequest)
response.Error = errors.KindInvalidArgument

switch e.extractor {
case queryTag, pathTag, headerTag:
response.ErrorDescription = http.StatusText(http.StatusBadRequest)
response.Fields[e.field] = fmt.Sprintf("with %s parameter: %s", e.fieldType, e.message)
case jsonTag:
response.ErrorDescription = e.message
}
case outputError:
w.WriteHeader(http.StatusInternalServerError)

response.Error = errors.KindInternal
response.ErrorDescription = e.Error()
}

// todo: use a log hook to log kcd real (critic) error

marshal, err := json.Marshal(response)
if err != nil {
return
}

_, _ = w.Write(marshal)

return
}

// defaultBindHook returns a Bind hook with the default logic, with configurable MaxBodyBytes.
func defaultBindHook(maxBodyBytes int64) BindHook {
return func(w http.ResponseWriter, r *http.Request, in interface{}) error {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if r.ContentLength == 0 {
return nil
}

bytesBody, err := ioutil.ReadAll(r.Body)
if err != nil {
return inputError{extractor: jsonTag, message: "unable to read body"}
}

if err := json.Unmarshal(bytesBody, in); err != nil {
return inputError{extractor: jsonTag, message: "unable to unmarshal request"}
}

return nil
}
}
Loading

0 comments on commit 5ac8051

Please sign in to comment.