+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+ Welcome to {{ .Defaults.CompanyName }}
+
+
+ Your account has been created, but is not yet
+ activated. Please click the link below to activate
+ your account.
+
+
+
+ If you did not create this account you can ignore this
+ email.
+
+
+ Thanks for using {{ .Defaults.CompanyName }}!
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
diff --git a/backend/pkgs/mailer/test-mailer-template.json b/backend/pkgs/mailer/test-mailer-template.json
new file mode 100644
index 0000000..9ff353e
--- /dev/null
+++ b/backend/pkgs/mailer/test-mailer-template.json
@@ -0,0 +1,7 @@
+{
+ "host": "",
+ "port": 465,
+ "username": "",
+ "password": "",
+ "from": ""
+}
\ No newline at end of file
diff --git a/backend/pkgs/server/constants.go b/backend/pkgs/server/constants.go
new file mode 100644
index 0000000..1d07ef5
--- /dev/null
+++ b/backend/pkgs/server/constants.go
@@ -0,0 +1,7 @@
+package server
+
+const (
+ ContentType = "Content-Type"
+ ContentJSON = "application/json"
+ ContentXML = "application/xml"
+)
diff --git a/backend/pkgs/server/request.go b/backend/pkgs/server/request.go
new file mode 100644
index 0000000..c4b30a4
--- /dev/null
+++ b/backend/pkgs/server/request.go
@@ -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)
+}
diff --git a/backend/pkgs/server/request_test.go b/backend/pkgs/server/request_test.go
new file mode 100644
index 0000000..05dc8c5
--- /dev/null
+++ b/backend/pkgs/server/request_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/pkgs/server/response.go b/backend/pkgs/server/response.go
new file mode 100644
index 0000000..d4d008f
--- /dev/null
+++ b/backend/pkgs/server/response.go
@@ -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"))
+}
diff --git a/backend/pkgs/server/response_error_builder.go b/backend/pkgs/server/response_error_builder.go
new file mode 100644
index 0000000..ac8d34d
--- /dev/null
+++ b/backend/pkgs/server/response_error_builder.go
@@ -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))
+}
diff --git a/backend/pkgs/server/response_error_builder_test.go b/backend/pkgs/server/response_error_builder_test.go
new file mode 100644
index 0000000..012e744
--- /dev/null
+++ b/backend/pkgs/server/response_error_builder_test.go
@@ -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")
+
+}
diff --git a/backend/pkgs/server/response_test.go b/backend/pkgs/server/response_test.go
new file mode 100644
index 0000000..2e98365
--- /dev/null
+++ b/backend/pkgs/server/response_test.go
@@ -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}`)
+
+}
diff --git a/backend/pkgs/server/result.go b/backend/pkgs/server/result.go
new file mode 100644
index 0000000..c2340a5
--- /dev/null
+++ b/backend/pkgs/server/result.go
@@ -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
+}
diff --git a/backend/pkgs/server/server.go b/backend/pkgs/server/server.go
new file mode 100644
index 0000000..628f234
--- /dev/null
+++ b/backend/pkgs/server/server.go
@@ -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()
+ })
+}
diff --git a/backend/pkgs/server/server_test.go b/backend/pkgs/server/server_test.go
new file mode 100644
index 0000000..18eed9e
--- /dev/null
+++ b/backend/pkgs/server/server_test.go
@@ -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)
+}
diff --git a/backend/pkgs/server/worker.go b/backend/pkgs/server/worker.go
new file mode 100644
index 0000000..682d5d6
--- /dev/null
+++ b/backend/pkgs/server/worker.go
@@ -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()
+}
diff --git a/backend/static/favicon.ico b/backend/static/favicon.ico
new file mode 100644
index 0000000..c6f7f74
Binary files /dev/null and b/backend/static/favicon.ico differ
diff --git a/client/client/index.ts b/client/client/index.ts
new file mode 100644
index 0000000..ceddad6
--- /dev/null
+++ b/client/client/index.ts
@@ -0,0 +1,5 @@
+import { v1ApiClient } from "./v1client";
+
+export function getClientV1(baseUrl: string): v1ApiClient {
+ return new v1ApiClient(baseUrl, "v1");
+}
diff --git a/client/client/v1client.ts b/client/client/v1client.ts
new file mode 100644
index 0000000..fcf93d7
--- /dev/null
+++ b/client/client/v1client.ts
@@ -0,0 +1,93 @@
+import axios, { Axios } from "axios";
+
+interface Wrap