Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
diamondburned committed Feb 28, 2023
0 parents commit 10304c2
Show file tree
Hide file tree
Showing 7 changed files with 434 additions and 0 deletions.
13 changes: 13 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Copyright 2023 diamondburned

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
136 changes: 136 additions & 0 deletions decoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package hrt

import (
"net/http"
"reflect"
"strconv"

"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
)

// Decoder describes a decoder that decodes the request type.
type Decoder interface {
// Decode decodes the given value from the given reader.
Decode(*http.Request, any) error
}

// MethodDecoder is an encoder that only encodes or decodes if the request
// method matches the methods in it.
type MethodDecoder map[string]Decoder

// Decode implements the Decoder interface.
func (e MethodDecoder) Decode(r *http.Request, v any) error {
dec, ok := e[r.Method]
if !ok {
dec, ok = e["*"]
}
if !ok {
return WrapHTTPError(http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
return dec.Decode(r, v)
}

// URLDecoder decodes chi.URLParams and url.Values into a struct. It only does
// Decoding; the Encode method is a no-op. The decoder makes no effort to
// traverse the struct and decode nested structs. If neither a chi.URLParam nor
// a url.Value is found for a field, the field is left untouched.
//
// For the sake of supporting code generators, the decoder also reads the `json`
// tag if the `url` tag is not present.
//
// # Example
//
// The following Go type would be decoded to have 2 URL parameters:
//
// type Data struct {
// ID string
// Num int `url:"num"`
// Nested struct {
// ID string
// }
// }
//
var URLDecoder Decoder = urlDecoder{}

type urlDecoder struct{}

func (d urlDecoder) Decode(r *http.Request, v any) error {
rv := reflect.Indirect(reflect.ValueOf(v))
if !rv.IsValid() {
return errors.New("invalid value")
}

if rv.Kind() != reflect.Struct {
return errors.New("value is not a struct")
}

rt := rv.Type()
nfields := rv.NumField()

for i := 0; i < nfields; i++ {
rfv := rv.Field(i)
rft := rt.Field(i)
if !rft.IsExported() {
continue
}

var name string
if tag := rft.Tag.Get("json"); tag != "" {
name = tag
} else if tag := rft.Tag.Get("url"); tag != "" {
name = tag
} else {
name = rft.Name
}

value := chi.URLParam(r, name)
if value == "" {
value = r.FormValue(name)
}
if value == "" {
continue
}

setPrimitiveFromString(rfv.Type(), rfv, value)
}

return nil
}

func setPrimitiveFromString(rf reflect.Type, rv reflect.Value, s string) error {
switch rf.Kind() {
case reflect.String:
rv.SetString(s)

case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
i, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return errors.Wrap(err, "invalid int")
}
rv.SetInt(i)

case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
i, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return errors.Wrap(err, "invalid uint")
}
rv.SetUint(i)

case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return errors.Wrap(err, "invalid float")
}
rv.SetFloat(f)

case reflect.Bool:
b, err := strconv.ParseBool(s)
if err != nil {
return errors.Wrap(err, "invalid bool")
}
rv.SetBool(b)
}

return nil
}
98 changes: 98 additions & 0 deletions encoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package hrt

import (
"encoding/json"
"net/http"
)

// DefaultEncoder is the default encoder used by the router. It decodes GET
// requests using the query string and URL parameter; everything else uses JSON.
var DefaultEncoder = CombinedEncoder{
Encoder: JSONEncoder,
Decoder: MethodDecoder{
// For the sake of being RESTful, we use a URLDecoder for GET requests.
"GET": URLDecoder,
// Everything else will be decoded as JSON.
"*": JSONEncoder,
},
}

// Encoder describes an encoder that encodes or decodes the request and response
// types.
type Encoder interface {
// Encode encodes the given value into the given writer.
Encode(http.ResponseWriter, any) error
// An encoder must be able to decode the same type it encodes.
Decoder
}

// CombinedEncoder combines an encoder and decoder pair into one.
type CombinedEncoder struct {
Encoder Encoder
Decoder Decoder
}

// Encode implements the Encoder interface.
func (e CombinedEncoder) Encode(w http.ResponseWriter, v any) error {
return e.Encoder.Encode(w, v)
}

// Decode implements the Decoder interface.
func (e CombinedEncoder) Decode(r *http.Request, v any) error {
return e.Decoder.Decode(r, v)
}

// JSONEncoder is an encoder that encodes and decodes JSON.
var JSONEncoder Encoder = jsonEncoder{}

type jsonEncoder struct{}

func (e jsonEncoder) Encode(w http.ResponseWriter, v any) error {
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(v)
}

func (e jsonEncoder) Decode(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}

// Validator describes a type that can validate itself.
type Validator interface {
Validate() error
}

// EncoderWithValidator wraps an encoder with one that calls Validate() on the
// value after decoding and before encoding if the value implements Validator.
func EncoderWithValidator(enc Encoder) Encoder {
return validatorEncoder{enc}
}

type validatorEncoder struct{ enc Encoder }

func (e validatorEncoder) Encode(w http.ResponseWriter, v any) error {
if validator, ok := v.(Validator); ok {
if err := validator.Validate(); err != nil {
return err
}
}

if err := e.enc.Encode(w, v); err != nil {
return err
}

return nil
}

func (e validatorEncoder) Decode(r *http.Request, v any) error {
if err := e.enc.Decode(r, v); err != nil {
return err
}

if validator, ok := v.(Validator); ok {
if err := validator.Validate(); err != nil {
return err
}
}

return nil
}
70 changes: 70 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package hrt

import (
"errors"
"fmt"
"net/http"
)

// HTTPError extends the error interface with an HTTP status code.
type HTTPError interface {
error
HTTPStatus() int
}

// ErrorHTTPStatus returns the HTTP status code for the given error. If the
// error is not an HTTPError, it returns defaultCode.
func ErrorHTTPStatus(err error, defaultCode int) int {
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.HTTPStatus()
}
return defaultCode
}

type wrappedHTTPError struct {
code int
err error
}

// WrapHTTPError wraps an error with an HTTP status code.
func WrapHTTPError(code int, err error) HTTPError {
return wrappedHTTPError{code, err}
}

func (e wrappedHTTPError) HTTPStatus() int {
return e.code
}

func (e wrappedHTTPError) Error() string {
return fmt.Sprintf("error status %d: %s", e.code, e.err)
}

func (e wrappedHTTPError) Unwrap() error {
return e.err
}

// ErrorWriter is a writer that writes an error to the response.
type ErrorWriter interface {
WriteError(w http.ResponseWriter, err error)
}

// WriteErrorFunc is a function that implements the ErrorWriter interface.
type WriteErrorFunc func(w http.ResponseWriter, err error)

// WriteError implements the ErrorWriter interface.
func (f WriteErrorFunc) WriteError(w http.ResponseWriter, err error) {
f(w, err)
}

// TextErrorWriter writes the error into the response in plain text. 500
// status code is used by default.
var TextErrorWriter ErrorWriter = textErrorWriter{}

type textErrorWriter struct{}

func (textErrorWriter) WriteError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(ErrorHTTPStatus(err, http.StatusInternalServerError))
fmt.Fprintln(w, err)
}
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/diamondburned/hrt

go 1.18

require (
github.com/go-chi/chi/v5 v5.0.8
github.com/pkg/errors v0.9.1
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
Loading

0 comments on commit 10304c2

Please sign in to comment.