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,56 @@
# Automapper
Automapper is an opinionated Go library that provides a dead simple interface to mapping 1-1 models To/From a database Model to a DTO or Schema using value semantics. It does not rely on code comments, but instead uses standard Go code to define your mapping and configuration to make it easy to use an refactor.
Current Limitation
- flat/single level models
- single schema to model per config entry
- limited configuration (support lowercase, camelcase, snakecase, etc)
Future Considerations
- [ ] Recursive mapping of embed structs
- [ ] Optional generate time type checker.
- [ ] Ensure values are copied to the destination and not just a reference
- [ ] ?!?!?
## Example Configuration
```go
package main
import (
"github.com/mealie-recipes/mealie-analytics/ent"
"github.com/mealie-recipes/mealie-analytics/internal/types"
"github.com/mealie-recipes/mealie-analytics/pkgs/automapper"
)
// getMappers serialized the config file into a list of automapper struct
func getMappers() []automapper.AutoMapper {
return []automapper.AutoMapper{
{
Package: "mapper", // generated package name
Prefix: "analytics", // generating file prefix -> analytics_automapper.go
Name: "Mealie Analytics", // For console output
Schema: automapper.Schema{
Type: types.Analytics{},
Prefix: "types", // Package namespace
},
Model: automapper.Model{
Type: ent.Analytics{},
Prefix: "ent", // Package namespace
},
Imports: []string{}, // Specify additional imports here
},
}
}
func main() {
automappers := getMappers()
conf := automapper.DefaultConf()
automapper.Generate(automappers, conf)
}
```

View file

@ -0,0 +1,92 @@
package automapper
import (
"bytes"
"fmt"
"go/format"
"os"
"reflect"
"strings"
"text/template"
)
type FieldAssignment struct {
ModelField string
SchemaField string
}
type Model struct {
Type interface{}
Prefix string
Fields []reflect.StructField
Reference string
}
type Schema struct {
Name string
Type interface{}
Prefix string
Fields []reflect.StructField
Reference string
}
type AutoMapper struct {
Name string
Package string
Prefix string
Schema Schema
Model Model
Imports []string
FieldAssignments []FieldAssignment
}
func (mapper *AutoMapper) ExecuteTemplates(conf *AutoMapperConf) {
t := template.New("automapper")
t, err := t.Parse(automapperTemplate)
if err != nil {
fmt.Println(err)
}
// Ensure the output directory exists
os.MkdirAll(conf.OutDir, 0755)
var path = fmt.Sprintf("%s/%s", conf.OutDir, mapper.GetFileName())
f, err := os.Create(path)
if err != nil {
panic(err)
}
defer f.Close()
var buf bytes.Buffer
err = t.Execute(&buf, mapper)
if err != nil {
fmt.Println(err)
}
text, err := format.Source(buf.Bytes())
if err != nil {
fmt.Println(err)
}
f.Write(text)
}
// GetFileName returns the computed file name based off user preference.
// If the Prefix has been specified on the AutoMapper it will be used
// in place of the Struct name. If the Prefix is not specified, the
// Struct name will be used.
//
// Examples:
// prefix_automapper.go
// mystructname_automapper.go
func (mapper *AutoMapper) GetFileName() string {
if mapper.Prefix == "" {
return strings.ToLower(mapper.Schema.Reference) + "_" + "automapper.go"
}
return strings.ToLower(mapper.Prefix) + "_" + "automapper.go"
}

View file

@ -0,0 +1,11 @@
package automapper
type AutoMapperConf struct {
OutDir string
}
func DefaultConf() *AutoMapperConf {
return &AutoMapperConf{
OutDir: "internal/mapper",
}
}

View file

@ -0,0 +1,48 @@
package automapper
import (
"fmt"
"reflect"
"strings"
)
func Generate(automappers []AutoMapper, conf *AutoMapperConf) {
for _, mapper := range automappers {
modelType := reflect.TypeOf(mapper.Model.Type)
transferObjectType := reflect.TypeOf(mapper.Schema.Type)
fmt.Printf("%s: %s -> %s\n", mapper.Name, modelType.Name(), transferObjectType.Name())
// From Fields
mapper.Imports = append(mapper.Imports, modelType.PkgPath())
mapper.Model.Reference = modelType.Name()
mapper.Model.Fields = make([]reflect.StructField, 0)
for i := 0; i < modelType.NumField(); i++ {
mapper.Model.Fields = append(mapper.Model.Fields, modelType.Field(i))
}
// To Fields
mapper.Imports = append(mapper.Imports, transferObjectType.PkgPath())
mapper.Schema.Reference = transferObjectType.Name()
mapper.Schema.Fields = make([]reflect.StructField, 0)
for i := 0; i < transferObjectType.NumField(); i++ {
mapper.Schema.Fields = append(mapper.Schema.Fields, transferObjectType.Field(i))
}
// Determine Field Assignments by matching the To fields and From fields by name
mapper.FieldAssignments = make([]FieldAssignment, 0)
for _, toField := range mapper.Schema.Fields {
for _, fromField := range mapper.Model.Fields {
if strings.EqualFold(toField.Name, fromField.Name) {
mapper.FieldAssignments = append(mapper.FieldAssignments, FieldAssignment{
ModelField: fromField.Name,
SchemaField: toField.Name,
})
}
}
}
mapper.ExecuteTemplates(conf)
}
}

View file

@ -0,0 +1,22 @@
package automapper
var automapperTemplate = `// Code generated by "/pkgs/automapper"; DO NOT EDIT.
package {{ .Package }}
import (
{{ range $import := .Imports }}"{{ $import }}"
{{ end }}
)
func {{ .Schema.Reference }}FromModel(from {{ .Model.Prefix}}.{{ .Model.Reference }}) {{ .Schema.Prefix}}.{{ .Schema.Reference }} {
return {{ .Schema.Prefix}}.{{ .Schema.Reference }}{ {{ range $i, $f := .FieldAssignments }}
{{ $f.SchemaField }}: from.{{ $f.ModelField }},{{ end }}
}
}
func {{ .Schema.Reference }}ToModel(from {{ .Schema.Prefix}}.{{ .Schema.Reference }}) {{ .Model.Prefix}}.{{ .Model.Reference }} {
return {{ .Model.Prefix}}.{{ .Model.Reference }}{ {{ range $i, $f := .FieldAssignments }}
{{ $f.ModelField }}: from.{{ $f.SchemaField }},{{ end }}
}
}
`

View file

@ -0,0 +1,37 @@
package faker
import (
"math/rand"
"time"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
type Faker struct {
}
func NewFaker() *Faker {
rand.Seed(time.Now().UnixNano())
return &Faker{}
}
func (f *Faker) RandomString(length int) string {
b := make([]rune, length)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func (f *Faker) RandomEmail() string {
return f.RandomString(10) + "@email.com"
}
func (f *Faker) RandomBool() bool {
return rand.Intn(2) == 1
}
func (f *Faker) RandomNumber(min, max int) int {
return rand.Intn(max-min) + min
}

View file

@ -0,0 +1,95 @@
package faker
import (
"testing"
)
const Loops = 500
func ValidateUnique(values []string) bool {
for i := 0; i < len(values); i++ {
for j := i + 1; j < len(values); j++ {
if values[i] == values[j] {
return false
}
}
}
return true
}
func Test_GetRandomString(t *testing.T) {
t.Parallel()
// Test that the function returns a string of the correct length
var generated = make([]string, Loops)
faker := NewFaker()
for i := 0; i < Loops; i++ {
generated[i] = faker.RandomString(10)
}
if !ValidateUnique(generated) {
t.Error("Generated values are not unique")
}
}
func Test_GetRandomEmail(t *testing.T) {
t.Parallel()
// Test that the function returns a string of the correct length
var generated = make([]string, Loops)
faker := NewFaker()
for i := 0; i < Loops; i++ {
generated[i] = faker.RandomEmail()
}
if !ValidateUnique(generated) {
t.Error("Generated values are not unique")
}
}
func Test_GetRandomBool(t *testing.T) {
t.Parallel()
var trues = 0
var falses = 0
faker := NewFaker()
for i := 0; i < Loops; i++ {
if faker.RandomBool() {
trues++
} else {
falses++
}
}
if trues == 0 || falses == 0 {
t.Error("Generated boolean don't appear random")
}
}
func Test_RandomNumber(t *testing.T) {
t.Parallel()
f := NewFaker()
const MIN = 0
const MAX = 100
last := MIN - 1
for i := 0; i < Loops; i++ {
n := f.RandomNumber(MIN, MAX)
if n == last {
t.Errorf("RandomNumber() failed to generate unique number")
}
if n < MIN || n > MAX {
t.Errorf("RandomNumber() failed to generate a number between %v and %v", MIN, MAX)
}
}
}

View file

@ -0,0 +1,13 @@
package hasher
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View file

@ -0,0 +1,40 @@
package hasher
import "testing"
func TestHashPassword(t *testing.T) {
t.Parallel()
type args struct {
password string
}
tests := []struct {
name string
args args
wantErr bool
}{
{
name: "letters_and_numbers",
args: args{
password: "password123456788",
},
},
{
name: "letters_number_and_special",
args: args{
password: "!2afj3214pofajip3142j;fa",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := HashPassword(tt.args.password)
if (err != nil) != tt.wantErr {
t.Errorf("HashPassword() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !CheckPasswordHash(tt.args.password, got) {
t.Errorf("CheckPasswordHash() failed to validate password=%v against hash=%v", tt.args.password, got)
}
})
}
}

View file

@ -0,0 +1,30 @@
package hasher
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
)
type Token struct {
Raw string
Hash []byte
}
func GenerateToken() Token {
randomBytes := make([]byte, 16)
rand.Read(randomBytes)
plainText := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
hash := HashToken(plainText)
return Token{
Raw: plainText,
Hash: hash,
}
}
func HashToken(plainTextToken string) []byte {
hash := sha256.Sum256([]byte(plainTextToken))
return hash[:]
}

View file

@ -0,0 +1,44 @@
package hasher
import (
"testing"
"github.com/stretchr/testify/assert"
)
const ITERATIONS = 200
func Test_NewToken(t *testing.T) {
t.Parallel()
tokens := make([]Token, ITERATIONS)
for i := 0; i < ITERATIONS; i++ {
tokens[i] = GenerateToken()
}
// Check if they are unique
for i := 0; i < 5; i++ {
for j := i + 1; j < 5; j++ {
if tokens[i].Raw == tokens[j].Raw {
t.Errorf("NewToken() failed to generate unique tokens")
}
}
}
}
func Test_HashToken_CheckTokenHash(t *testing.T) {
t.Parallel()
for i := 0; i < ITERATIONS; i++ {
token := GenerateToken()
// Check raw text is reltively random
for j := 0; j < 5; j++ {
assert.NotEqual(t, token.Raw, GenerateToken().Raw)
}
// Check token length is less than 32 characters
assert.Less(t, len(token.Raw), 32)
// Check hash is the same
assert.Equal(t, token.Hash, HashToken(token.Raw))
}
}

View file

@ -0,0 +1,121 @@
package logger
import (
"encoding/json"
"io"
"os"
"runtime/debug"
"sync"
"time"
)
type Level int8
const (
LevelDebug Level = iota
LevelInfo
LevelError
LevelFatal
LevelOff
)
func (l Level) String() string {
switch l {
case LevelDebug:
return "DEBUG"
case LevelInfo:
return "INFO"
case LevelError:
return "ERROR"
case LevelFatal:
return "FATAL"
default:
return ""
}
}
type Props map[string]string
type Logger struct {
out io.Writer
minLevel Level
mu sync.Mutex
}
func New(out io.Writer, minLevel Level) *Logger {
return &Logger{
out: out,
minLevel: minLevel,
}
}
func (l *Logger) Debug(message string, properties map[string]string) {
l.print(LevelDebug, message, properties)
}
func (l *Logger) Info(message string, properties map[string]string) {
l.print(LevelInfo, message, properties)
}
func (l *Logger) Error(err error, properties map[string]string) {
l.print(LevelError, err.Error(), properties)
}
func (l *Logger) Fatal(err error, properties map[string]string) {
l.print(LevelFatal, err.Error(), properties)
os.Exit(1) // For entries at the FATAL level, we also terminate the application.
}
func (l *Logger) print(level Level, message string, properties map[string]string) (int, error) {
// If the severity level of the log entry is below the minimum severity for the
// logger, then return with no further action.
if level < l.minLevel {
return 0, nil
}
// Declare an anonymous struct holding the data for the log entry.
aux := struct {
Level string `json:"level"`
Time string `json:"time"`
Message string `json:"message"`
Properties map[string]string `json:"properties,omitempty"`
Trace string `json:"trace,omitempty"`
}{
Level: level.String(),
Time: time.Now().UTC().Format(time.RFC3339),
Message: message,
Properties: properties,
}
// Include a stack trace for entries at the ERROR and FATAL levels.
if level >= LevelError {
aux.Trace = string(debug.Stack())
}
// Declare a line variable for holding the actual log entry text.
var line []byte
// Marshal the anonymous struct to JSON and store it in the line variable. If there
// was a problem creating the JSON, set the contents of the log entry to be that
// plain-text error message instead.”
line, err := json.Marshal(aux)
if err != nil {
line = []byte(LevelError.String() + ": unable to marshal log message:" + err.Error())
}
// Lock the mutex so that no two writes to the output destination cannot happen
// concurrently. If we don't do this, it's possible that the text for two or more
// log entries will be intermingled in the output.
l.mu.Lock()
defer l.mu.Unlock()
// Write the log entry followed by a newline.
return l.out.Write(append(line, '\n'))
}
// We also implement a Write() method on our Logger type so that it satisfies the
// io.Writer interface. This writes a log entry at the ERROR level with no additional
// properties.
func (l *Logger) Write(message []byte) (n int, err error) {
return l.print(LevelError, string(message), nil)
}

View file

@ -0,0 +1,119 @@
package logger
import (
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
var lastWrite = []byte{}
type testLogRecorder struct {
t *testing.T
}
func (tlr testLogRecorder) Write(p []byte) (n int, err error) {
lastWrite = p
return len(p), nil
}
type logEntry struct {
Level string `json:"level"`
Message string `json:"message"`
Props *Props `json:"properties"`
}
func (lr *logEntry) Unmarshal(t *testing.T, jbytes []byte) {
err := json.Unmarshal(jbytes, lr)
if err != nil {
t.Error(err)
}
}
func Test_LevelString(t *testing.T) {
assert.Equal(t, "DEBUG", LevelDebug.String())
assert.Equal(t, "INFO", LevelInfo.String())
assert.Equal(t, "ERROR", LevelError.String())
assert.Equal(t, "FATAL", LevelFatal.String())
assert.Equal(t, "", LevelOff.String())
}
func Test_NewLogger(t *testing.T) {
logRecorder := testLogRecorder{t: t}
logger := New(logRecorder, LevelInfo)
assert.NotNil(t, logger)
}
func getTestLogger(t *testing.T, level Level) *Logger {
logRecorder := testLogRecorder{t: t}
logger := New(logRecorder, level)
assert.NotNil(t, logger)
return logger
}
func checkLastEntry(t *testing.T, level Level, message string, props *Props) {
entry := &logEntry{}
entry.Unmarshal(t, lastWrite)
assert.Equal(t, level.String(), entry.Level)
assert.Equal(t, message, entry.Message)
assert.Equal(t, props, entry.Props)
}
func Test_LoggerDebug(t *testing.T) {
lgr := getTestLogger(t, LevelDebug)
lgr.Debug("Test Debug", Props{"Hello": "World"})
checkLastEntry(t, LevelDebug, "Test Debug", &Props{"Hello": "World"})
lastWrite = []byte{}
}
func Test_LoggerInfo(t *testing.T) {
lgr := getTestLogger(t, LevelInfo)
lgr.Info("Test Info", Props{"Hello": "World"})
checkLastEntry(t, LevelInfo, "Test Info", &Props{"Hello": "World"})
lastWrite = []byte{}
}
func Test_LoggerError(t *testing.T) {
lgr := getTestLogger(t, LevelError)
myerror := errors.New("Test Error")
lgr.Error(myerror, Props{"Hello": "World"})
checkLastEntry(t, LevelError, "Test Error", &Props{"Hello": "World"})
lastWrite = []byte{}
}
func Test_LoggerLevelScale(t *testing.T) {
lgr := getTestLogger(t, LevelInfo)
lastWrite = []byte{}
lgr.Debug("Test Debug", Props{"Hello": "World"})
assert.Equal(t, []byte{}, lastWrite)
lgr = getTestLogger(t, LevelError)
lastWrite = []byte{}
lgr.Info("Test Debug", Props{"Hello": "World"})
lgr.Debug("Test Debug", Props{"Hello": "World"})
assert.Equal(t, []byte{}, lastWrite)
lgr = getTestLogger(t, LevelFatal)
lgr.Info("Test Debug", Props{"Hello": "World"})
lgr.Debug("Test Debug", Props{"Hello": "World"})
lgr.Error(errors.New("Test Error"), Props{"Hello": "World"})
assert.Equal(t, []byte{}, lastWrite)
}

View file

@ -0,0 +1,51 @@
package mailer
import (
"encoding/base64"
"fmt"
"mime"
"net/smtp"
"strconv"
)
type Mailer struct {
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
From string `json:"from,omitempty"`
}
func (m *Mailer) Ready() bool {
return m.Host != "" && m.Port != 0 && m.Username != "" && m.Password != "" && m.From != ""
}
func (m *Mailer) server() string {
return m.Host + ":" + strconv.Itoa(m.Port)
}
func (m *Mailer) Send(msg *Message) error {
server := m.server()
header := make(map[string]string)
header["From"] = msg.From.String()
header["To"] = msg.To.String()
header["Subject"] = mime.QEncoding.Encode("UTF-8", msg.Subject)
header["MIME-Version"] = "1.0"
header["Content-Type"] = "text/html; charset=\"utf-8\""
header["Content-Transfer-Encoding"] = "base64"
message := ""
for k, v := range header {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + base64.StdEncoding.EncodeToString([]byte(msg.Body))
return smtp.SendMail(
server,
smtp.PlainAuth("", m.Username, m.Password, m.Host),
m.From,
[]string{msg.To.Address},
[]byte(message),
)
}

View file

@ -0,0 +1,66 @@
package mailer
import (
"encoding/json"
"io/ioutil"
"testing"
"github.com/stretchr/testify/assert"
)
const (
TestMailerConfig = "test-mailer.json"
)
func GetTestMailer() (*Mailer, error) {
// Read JSON File
bytes, err := ioutil.ReadFile(TestMailerConfig)
mailer := &Mailer{}
if err != nil {
return nil, err
}
// Unmarshal JSON
err = json.Unmarshal(bytes, mailer)
if err != nil {
return nil, err
}
return mailer, nil
}
func Test_Mailer(t *testing.T) {
t.Parallel()
mailer, err := GetTestMailer()
if err != nil {
t.Skip("Error Reading Test Mailer Config - Skipping")
}
if !mailer.Ready() {
t.Skip("Mailer not ready - Skipping")
}
message, err := RenderWelcome()
if err != nil {
t.Error(err)
}
mb := NewMessageBuilder().
SetBody(message).
SetSubject("Hello").
SetTo("John Doe", "john@doe.com").
SetFrom("Jane Doe", "jane@doe.com")
msg := mb.Build()
err = mailer.Send(msg)
assert.Nil(t, err)
}

View file

@ -0,0 +1,56 @@
package mailer
import "net/mail"
type Message struct {
Subject string
To mail.Address
From mail.Address
Body string
}
type MessageBuilder struct {
subject string
to mail.Address
from mail.Address
body string
}
func NewMessageBuilder() *MessageBuilder {
return &MessageBuilder{}
}
func (mb *MessageBuilder) Build() *Message {
return &Message{
Subject: mb.subject,
To: mb.to,
From: mb.from,
Body: mb.body,
}
}
func (mb *MessageBuilder) SetSubject(subject string) *MessageBuilder {
mb.subject = subject
return mb
}
func (mb *MessageBuilder) SetTo(name, to string) *MessageBuilder {
mb.to = mail.Address{
Name: name,
Address: to,
}
return mb
}
func (mb *MessageBuilder) SetFrom(name, from string) *MessageBuilder {
mb.from = mail.Address{
Name: name,
Address: from,
}
return mb
}
func (mb *MessageBuilder) SetBody(body string) *MessageBuilder {
mb.body = body
return mb
}

View file

@ -0,0 +1,26 @@
package mailer
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_MessageBuilder(t *testing.T) {
t.Parallel()
mb := NewMessageBuilder().
SetBody("Hello World!").
SetSubject("Hello").
SetTo("John Doe", "john@doe.com").
SetFrom("Jane Doe", "jane@doe.com")
msg := mb.Build()
assert.Equal(t, "Hello", msg.Subject)
assert.Equal(t, "Hello World!", msg.Body)
assert.Equal(t, "John Doe", msg.To.Name)
assert.Equal(t, "john@doe.com", msg.To.Address)
assert.Equal(t, "Jane Doe", msg.From.Name)
assert.Equal(t, "jane@doe.com", msg.From.Address)
}

View file

@ -0,0 +1,62 @@
package mailer
import (
"bytes"
_ "embed"
"html/template"
)
//go:embed templates/welcome.html
var templatesWelcome string
type TemplateDefaults struct {
CompanyName string
CompanyAddress string
CompanyURL string
ActivateAccountURL string
UnsubscribeURL string
}
type TemplateProps struct {
Defaults TemplateDefaults
Data map[string]string
}
func (tp *TemplateProps) Set(key, value string) {
tp.Data[key] = value
}
func DefaultTemplateData() TemplateProps {
return TemplateProps{
Defaults: TemplateDefaults{
CompanyName: "Haybytes.com",
CompanyAddress: "123 Main St, Anytown, CA 12345",
CompanyURL: "https://haybytes.com",
ActivateAccountURL: "https://google.com",
UnsubscribeURL: "https://google.com",
},
Data: make(map[string]string),
}
}
func render(tpl string, data TemplateProps) (string, error) {
tmpl, err := template.New("name").Parse(tpl)
if err != nil {
return "", err
}
var tplBuffer bytes.Buffer
err = tmpl.Execute(&tplBuffer, data)
if err != nil {
return "", err
}
return tplBuffer.String(), nil
}
func RenderWelcome() (string, error) {
return render(templatesWelcome, DefaultTemplateData())
}

View file

@ -0,0 +1,444 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Welcome!</title>
<style>
@media only screen and (max-width: 620px) {
table.body h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table.body p,
table.body ul,
table.body ol,
table.body td,
table.body span,
table.body a {
font-size: 16px !important;
}
table.body .wrapper,
table.body .article {
padding: 10px !important;
}
table.body .content {
padding: 0 !important;
}
table.body .container {
padding: 0 !important;
width: 100% !important;
}
table.body .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table.body .btn table {
width: 100% !important;
}
table.body .btn a {
width: 100% !important;
}
table.body .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body
style="
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
"
>
<span
class="preheader"
style="
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
"
>This is preheader text. Some clients will show this text as a
preview.</span
>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
class="body"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
background-color: #f6f6f6;
width: 100%;
"
width="100%"
bgcolor="#f6f6f6"
>
<tr>
<td
style="font-family: sans-serif; font-size: 14px; vertical-align: top"
valign="top"
>
&nbsp;
</td>
<td
class="container"
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
display: block;
max-width: 580px;
padding: 10px;
width: 580px;
margin: 0 auto;
"
width="580"
valign="top"
>
<div
class="content"
style="
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
"
>
<!-- START CENTERED WHITE CONTAINER -->
<table
role="presentation"
class="main"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
background: #ffffff;
border-radius: 3px;
width: 100%;
"
width="100%"
>
<!-- START MAIN CONTENT AREA -->
<tr>
<td
class="wrapper"
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
box-sizing: border-box;
padding: 20px;
"
valign="top"
>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
"
width="100%"
>
<tr>
<td
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
"
valign="top"
>
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
Welcome to {{ .Defaults.CompanyName }}
</p>
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
Your account has been created, but is not yet
activated. Please click the link below to activate
your account.
</p>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
class="btn btn-primary"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
box-sizing: border-box;
width: 100%;
"
width="100%"
>
<tbody>
<tr>
<td
align="left"
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
padding-bottom: 15px;
"
valign="top"
>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: auto;
"
>
<tbody>
<tr>
<td
style="
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
border-radius: 5px;
text-align: center;
background-color: #3498db;
"
valign="top"
align="center"
bgcolor="#3498db"
>
<a
href="{{ .Defaults.ActivateAccountURL }}"
target="_blank"
style="
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
background-color: #3498db;
border-color: #3498db;
color: #ffffff;
"
>
Activate Account
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
If you did not create this account you can ignore this
email.
</p>
<p
style="
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
"
>
Thanks for using {{ .Defaults.CompanyName }}!
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div
class="footer"
style="
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
"
>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
"
width="100%"
>
<tr>
<td
class="content-block"
style="
font-family: sans-serif;
vertical-align: top;
padding-bottom: 10px;
padding-top: 10px;
color: #999999;
font-size: 12px;
text-align: center;
"
valign="top"
align="center"
>
<span
class="apple-link"
style="
color: #999999;
font-size: 12px;
text-align: center;
"
>{{ .Defaults.CompanyName }}, {{ .Defaults.CompanyAddress
}}</span
>
<br />
Don't like these emails?
<a
href="{{ .Defaults.UnsubscribeURL }}"
style="
text-decoration: underline;
color: #999999;
font-size: 12px;
text-align: center;
"
>Unsubscribe</a
>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td
style="font-family: sans-serif; font-size: 14px; vertical-align: top"
valign="top"
>
&nbsp;
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,7 @@
{
"host": "",
"port": 465,
"username": "",
"password": "",
"from": ""
}

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()
}