Initial commit

This commit is contained in:
Hayden 2022-08-29 18:30:36 -08:00
commit 29f583e936
135 changed files with 18463 additions and 0 deletions

View file

@ -0,0 +1,7 @@
package server
const (
ContentType = "Content-Type"
ContentJSON = "application/json"
ContentXML = "application/xml"
)

View file

@ -0,0 +1,48 @@
package server
import (
"encoding/json"
"net/http"
)
// Decode reads the body of an HTTP request looking for a JSON document. The
// body is decoded into the provided value.
func Decode(r *http.Request, val interface{}) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(val); err != nil {
return err
}
return nil
}
// GetId is a shotcut to get the id from the request URL or return a default value
func GetParam(r *http.Request, key, d string) string {
val := r.URL.Query().Get(key)
if val == "" {
return d
}
return val
}
// GetSkip is a shotcut to get the skip from the request URL parameters
func GetSkip(r *http.Request, d string) string {
return GetParam(r, "skip", d)
}
// GetSkip is a shotcut to get the skip from the request URL parameters
func GetId(r *http.Request, d string) string {
return GetParam(r, "id", d)
}
// GetLimit is a shotcut to get the limit from the request URL parameters
func GetLimit(r *http.Request, d string) string {
return GetParam(r, "limit", d)
}
// GetQuery is a shotcut to get the sort from the request URL parameters
func GetQuery(r *http.Request, d string) string {
return GetParam(r, "query", d)
}

View file

@ -0,0 +1,210 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type TestStruct struct {
Name string `json:"name"`
Data string `json:"data"`
}
func TestDecode(t *testing.T) {
type args struct {
r *http.Request
val interface{}
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "check_error",
args: args{
r: &http.Request{
Body: http.NoBody,
},
val: make(map[string]interface{}),
},
wantErr: true,
},
{
name: "check_success",
args: args{
r: httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","data":"test"}`)),
val: TestStruct{
Name: "test",
Data: "test",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := Decode(tt.args.r, &tt.args.val); (err != nil) != tt.wantErr {
t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGetParam(t *testing.T) {
type args struct {
r *http.Request
key string
d string
}
tests := []struct {
name string
args args
want string
}{
{
name: "check_default",
args: args{
r: httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","data":"test"}`)),
key: "id",
d: "default",
},
want: "default",
},
{
name: "check_id",
args: args{
r: httptest.NewRequest("POST", "/item?id=123", strings.NewReader(`{"name":"test","data":"test"}`)),
key: "id",
d: "",
},
want: "123",
},
{
name: "check_query",
args: args{
r: httptest.NewRequest("POST", "/item?query=hello-world", strings.NewReader(`{"name":"test","data":"test"}`)),
key: "query",
d: "",
},
want: "hello-world",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetParam(tt.args.r, tt.args.key, tt.args.d); got != tt.want {
t.Errorf("GetParam() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetSkip(t *testing.T) {
type args struct {
r *http.Request
d string
}
tests := []struct {
name string
args args
want string
}{
{
name: "check_default",
args: args{
r: httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","data":"test"}`)),
d: "0",
},
want: "0",
},
{
name: "check_skip",
args: args{
r: httptest.NewRequest("POST", "/item?skip=107", strings.NewReader(`{"name":"test","data":"test"}`)),
d: "0",
},
want: "107",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetSkip(tt.args.r, tt.args.d); got != tt.want {
t.Errorf("GetSkip() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetLimit(t *testing.T) {
type args struct {
r *http.Request
d string
}
tests := []struct {
name string
args args
want string
}{
{
name: "check_default",
args: args{
r: httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","data":"test"}`)),
d: "0",
},
want: "0",
},
{
name: "check_limit",
args: args{
r: httptest.NewRequest("POST", "/item?limit=107", strings.NewReader(`{"name":"test","data":"test"}`)),
d: "0",
},
want: "107",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetLimit(tt.args.r, tt.args.d); got != tt.want {
t.Errorf("GetLimit() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetQuery(t *testing.T) {
type args struct {
r *http.Request
d string
}
tests := []struct {
name string
args args
want string
}{
{
name: "check_default",
args: args{
r: httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","data":"test"}`)),
d: "0",
},
want: "0",
},
{
name: "check_query",
args: args{
r: httptest.NewRequest("POST", "/item?query=hello-query", strings.NewReader(`{"name":"test","data":"test"}`)),
d: "0",
},
want: "hello-query",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetQuery(tt.args.r, tt.args.d); got != tt.want {
t.Errorf("GetQuery() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,61 @@
package server
import (
"encoding/json"
"errors"
"net/http"
)
// Respond converts a Go value to JSON and sends it to the client.
// Adapted from https://github.com/ardanlabs/service/tree/master/foundation/web
func Respond(w http.ResponseWriter, statusCode int, data interface{}) error {
// If there is nothing to marshal then set status code and return.
if statusCode == http.StatusNoContent {
w.WriteHeader(statusCode)
return nil
}
// Convert the response value to JSON.
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
// Set the content type and headers once we know marshaling has succeeded.
w.Header().Set("Content-Type", "application/json")
// Write the status code to the response.
w.WriteHeader(statusCode)
// Send the result back to the client.
if _, err := w.Write(jsonData); err != nil {
return err
}
return nil
}
// ResponseError is a helper function that sends a JSON response of an error message
func RespondError(w http.ResponseWriter, statusCode int, err error) {
eb := ErrorBuilder{}
eb.AddError(err)
eb.Respond(w, statusCode)
}
// RespondInternalServerError is a wrapper around RespondError that sends a 500 internal server error. Useful for
// Sending generic errors when everything went wrong.
func RespondInternalServerError(w http.ResponseWriter) {
RespondError(w, http.StatusInternalServerError, errors.New("internal server error"))
}
// RespondNotFound is a helper utility for responding with a generic
// "unauthorized" error.
func RespondUnauthorized(w http.ResponseWriter) {
RespondError(w, http.StatusUnauthorized, errors.New("unauthorized"))
}
// RespondForbidden is a helper utility for responding with a generic
// "forbidden" error.
func RespondForbidden(w http.ResponseWriter) {
RespondError(w, http.StatusForbidden, errors.New("forbidden"))
}

View file

@ -0,0 +1,51 @@
package server
import (
"net/http"
)
// ErrorBuilder is a helper type to build a response that contains an array of errors.
// Typical use cases are for returning an array of validation errors back to the user.
//
// Example:
//
//
// {
// "errors": [
// "invalid id",
// "invalid name",
// "invalid description"
// ],
// "message": "Unprocessable Entity",
// "status": 422
// }
//
type ErrorBuilder struct {
errs []string
}
// HasErrors returns true if the ErrorBuilder has any errors.
func (eb *ErrorBuilder) HasErrors() bool {
if (eb.errs == nil) || (len(eb.errs) == 0) {
return false
}
return true
}
// AddError adds an error to the ErrorBuilder if an error is not nil. If the
// Error is nil, then nothing is added.
func (eb *ErrorBuilder) AddError(err error) {
if err != nil {
if eb.errs == nil {
eb.errs = make([]string, 0)
}
eb.errs = append(eb.errs, err.Error())
}
}
// Respond sends a JSON response with the ErrorBuilder's errors. If there are no errors, then
// the errors field will be an empty array.
func (eb *ErrorBuilder) Respond(w http.ResponseWriter, statusCode int) {
Respond(w, statusCode, Wrap(nil).AddError(http.StatusText(statusCode), eb.errs))
}

View file

@ -0,0 +1,107 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/hay-kot/git-web-template/backend/pkgs/faker"
"github.com/stretchr/testify/assert"
)
func Test_ErrorBuilder_HasErrors_NilList(t *testing.T) {
t.Parallel()
var ebNilList = ErrorBuilder{}
assert.False(t, ebNilList.HasErrors(), "ErrorBuilder.HasErrors() should return false when list is nil")
}
func Test_ErrorBuilder_HasErrors_EmptyList(t *testing.T) {
t.Parallel()
var ebEmptyList = ErrorBuilder{
errs: []string{},
}
assert.False(t, ebEmptyList.HasErrors(), "ErrorBuilder.HasErrors() should return false when list is empty")
}
func Test_ErrorBuilder_HasErrors_WithError(t *testing.T) {
t.Parallel()
var ebList = ErrorBuilder{}
ebList.AddError(errors.New("test error"))
assert.True(t, ebList.HasErrors(), "ErrorBuilder.HasErrors() should return true when list is not empty")
}
func Test_ErrorBuilder_AddError(t *testing.T) {
t.Parallel()
randomError := make([]error, 10)
f := faker.NewFaker()
errorStrings := make([]string, 10)
for i := 0; i < 10; i++ {
err := errors.New(f.RandomString(10))
randomError[i] = err
errorStrings[i] = err.Error()
}
// Check Results
var ebList = ErrorBuilder{}
for _, err := range randomError {
ebList.AddError(err)
}
assert.Equal(t, errorStrings, ebList.errs, "ErrorBuilder.AddError() should add an error to the list")
}
func Test_ErrorBuilder_Respond(t *testing.T) {
t.Parallel()
f := faker.NewFaker()
randomError := make([]error, 5)
for i := 0; i < 5; i++ {
err := errors.New(f.RandomString(5))
randomError[i] = err
}
// Check Results
var ebList = ErrorBuilder{}
for _, err := range randomError {
ebList.AddError(err)
}
fakeWriter := httptest.NewRecorder()
ebList.Respond(fakeWriter, 422)
assert.Equal(t, 422, fakeWriter.Code, "ErrorBuilder.Respond() should return a status code of 422")
// Check errors payload is correct
errorsStruct := struct {
Errors []string `json:"details"`
Message string `json:"message"`
Error bool `json:"error"`
}{
Errors: ebList.errs,
Message: http.StatusText(http.StatusUnprocessableEntity),
Error: true,
}
asJson, _ := json.Marshal(errorsStruct)
assert.JSONEq(t, string(asJson), fakeWriter.Body.String(), "ErrorBuilder.Respond() should return a JSON response with the errors")
}

View file

@ -0,0 +1,78 @@
package server
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_Respond_NoContent(t *testing.T) {
recorder := httptest.NewRecorder()
dummystruct := struct {
Name string
}{
Name: "dummy",
}
Respond(recorder, http.StatusNoContent, dummystruct)
assert.Equal(t, http.StatusNoContent, recorder.Code)
assert.Empty(t, recorder.Body.String())
}
func Test_Respond_JSON(t *testing.T) {
recorder := httptest.NewRecorder()
dummystruct := struct {
Name string `json:"name"`
}{
Name: "dummy",
}
Respond(recorder, http.StatusCreated, dummystruct)
assert.Equal(t, http.StatusCreated, recorder.Code)
assert.JSONEq(t, recorder.Body.String(), `{"name":"dummy"}`)
assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
}
func Test_RespondError(t *testing.T) {
recorder := httptest.NewRecorder()
var customError = errors.New("custom error")
RespondError(recorder, http.StatusBadRequest, customError)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.JSONEq(t, recorder.Body.String(), `{"details":["custom error"], "message":"Bad Request", "error":true}`)
}
func Test_RespondInternalServerError(t *testing.T) {
recorder := httptest.NewRecorder()
RespondInternalServerError(recorder)
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
assert.JSONEq(t, recorder.Body.String(), `{"details":["internal server error"], "message":"Internal Server Error", "error":true}`)
}
func Test_RespondUnauthorized(t *testing.T) {
recorder := httptest.NewRecorder()
RespondUnauthorized(recorder)
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
assert.JSONEq(t, recorder.Body.String(), `{"details":["unauthorized"], "message":"Unauthorized", "error":true}`)
}
func Test_RespondForbidden(t *testing.T) {
recorder := httptest.NewRecorder()
RespondForbidden(recorder)
assert.Equal(t, http.StatusForbidden, recorder.Code)
assert.JSONEq(t, recorder.Body.String(), `{"details":["forbidden"], "message":"Forbidden", "error":true}`)
}

View file

@ -0,0 +1,27 @@
package server
type Result struct {
Error bool `json:"error,omitempty"`
Details interface{} `json:"details,omitempty"`
Message string `json:"message,omitempty"`
Item interface{} `json:"item,omitempty"`
}
// Wrap creates a Wrapper instance and adds the initial namespace and data to be returned.
func Wrap(data interface{}) Result {
return Result{
Item: data,
}
}
func (r Result) AddMessage(message string) Result {
r.Message = message
return r
}
func (r Result) AddError(err string, details interface{}) Result {
r.Message = err
r.Details = details
r.Error = true
return r
}

View file

@ -0,0 +1,123 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// TODO: #2 Implement Go routine pool/job queue
var ErrServerNotStarted = errors.New("server not started")
var ErrServerAlreadyStarted = errors.New("server already started")
type Server struct {
Host string
Port string
Worker Worker
wg sync.WaitGroup
started bool
activeServer *http.Server
}
func NewServer(host, port string) *Server {
return &Server{
Host: host,
Port: port,
wg: sync.WaitGroup{},
Worker: NewSimpleWorker(),
}
}
func (s *Server) Shutdown(sig string) error {
if !s.started {
return ErrServerNotStarted
}
fmt.Printf("Received %s signal, shutting down\n", sig)
// Create a context with a 5-second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := s.activeServer.Shutdown(ctx)
s.started = false
if err != nil {
return err
}
fmt.Println("Http server shutdown, waiting for all tasks to finish")
s.wg.Wait()
return nil
}
func (s *Server) Start(router http.Handler) error {
if s.started {
return ErrServerAlreadyStarted
}
s.activeServer = &http.Server{
Addr: s.Host + ":" + s.Port,
Handler: router,
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
shutdownError := make(chan error)
go func() {
// Create a quit channel which carries os.Signal values.
quit := make(chan os.Signal, 1)
// Use signal.Notify() to listen for incoming SIGINT and SIGTERM signals and
// relay them to the quit channel.
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Read the signal from the quit channel. block until received
sig := <-quit
err := s.Shutdown(sig.String())
if err != nil {
shutdownError <- err
}
// Exit the application with a 0 (success) status code.
os.Exit(0)
}()
s.started = true
err := s.activeServer.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
return err
}
err = <-shutdownError
if err != nil {
return err
}
fmt.Println("Server shutdown successfully")
return nil
}
// Background starts a go routine that runs on the servers pool. In the event of a shutdown
// request, the server will wait until all open goroutines have finished before shutting down.
func (svr *Server) Background(task func()) {
svr.wg.Add(1)
svr.Worker.Add(func() {
defer svr.wg.Done()
task()
})
}

View file

@ -0,0 +1,97 @@
package server
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func testServer(t *testing.T, r http.Handler) *Server {
svr := NewServer("127.0.0.1", "19245")
go func() {
svr.Start(r)
}()
ping := func() error {
_, err := http.Get("http://127.0.0.1:19245")
return err
}
for {
if err := ping(); err == nil {
break
}
time.Sleep(time.Millisecond * 100)
}
return svr
}
func Test_ServerShutdown_Error(t *testing.T) {
svr := NewServer("127.0.0.1", "19245")
err := svr.Shutdown("test")
assert.ErrorIs(t, err, ErrServerNotStarted)
}
func Test_ServerStarts_Error(t *testing.T) {
svr := testServer(t, nil)
err := svr.Start(nil)
assert.ErrorIs(t, err, ErrServerAlreadyStarted)
err = svr.Shutdown("test")
assert.NoError(t, err)
}
func Test_ServerStarts(t *testing.T) {
svr := testServer(t, nil)
err := svr.Shutdown("test")
assert.NoError(t, err)
}
func Test_GracefulServerShutdownWithWorkers(t *testing.T) {
isFinished := false
svr := testServer(t, nil)
svr.Background(func() {
time.Sleep(time.Second * 4)
isFinished = true
})
err := svr.Shutdown("test")
assert.NoError(t, err)
assert.True(t, isFinished)
}
func Test_GracefulServerShutdownWithRequests(t *testing.T) {
isFinished := false
router := http.NewServeMux()
// add long running handler func
router.HandleFunc("/test", func(rw http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second * 3)
isFinished = true
})
svr := testServer(t, router)
// Make request to "/test"
go func() {
http.Get("http://127.0.0.1:19245/test") // This is probably bad?
}()
time.Sleep(time.Second) // Hack to wait for the request to be made
err := svr.Shutdown("test")
assert.NoError(t, err)
assert.True(t, isFinished)
}

View file

@ -0,0 +1,20 @@
package server
type Worker interface {
Add(func())
}
// SimpleWorker is a simple background worker that implements
// the Worker interface and runs all tasks in a go routine without
// a pool or que or limits. It's useful for simple or small applications
// with minimal/short background tasks
type SimpleWorker struct {
}
func NewSimpleWorker() *SimpleWorker {
return &SimpleWorker{}
}
func (sw *SimpleWorker) Add(task func()) {
go task()
}