forked from mirrors/homebox
Initial commit
This commit is contained in:
commit
29f583e936
135 changed files with 18463 additions and 0 deletions
56
backend/pkgs/automapper/README.md
Normal file
56
backend/pkgs/automapper/README.md
Normal 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)
|
||||
}
|
||||
```
|
92
backend/pkgs/automapper/automapper.go
Normal file
92
backend/pkgs/automapper/automapper.go
Normal 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"
|
||||
|
||||
}
|
11
backend/pkgs/automapper/conf.go
Normal file
11
backend/pkgs/automapper/conf.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
package automapper
|
||||
|
||||
type AutoMapperConf struct {
|
||||
OutDir string
|
||||
}
|
||||
|
||||
func DefaultConf() *AutoMapperConf {
|
||||
return &AutoMapperConf{
|
||||
OutDir: "internal/mapper",
|
||||
}
|
||||
}
|
48
backend/pkgs/automapper/main.go
Normal file
48
backend/pkgs/automapper/main.go
Normal 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)
|
||||
}
|
||||
}
|
22
backend/pkgs/automapper/templates.go
Normal file
22
backend/pkgs/automapper/templates.go
Normal 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 }}
|
||||
}
|
||||
}
|
||||
`
|
37
backend/pkgs/faker/random.go
Normal file
37
backend/pkgs/faker/random.go
Normal 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
|
||||
}
|
95
backend/pkgs/faker/randoms_test.go
Normal file
95
backend/pkgs/faker/randoms_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
13
backend/pkgs/hasher/password.go
Normal file
13
backend/pkgs/hasher/password.go
Normal 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
|
||||
}
|
40
backend/pkgs/hasher/password_test.go
Normal file
40
backend/pkgs/hasher/password_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
30
backend/pkgs/hasher/token.go
Normal file
30
backend/pkgs/hasher/token.go
Normal 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[:]
|
||||
}
|
44
backend/pkgs/hasher/token_test.go
Normal file
44
backend/pkgs/hasher/token_test.go
Normal 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))
|
||||
}
|
||||
}
|
121
backend/pkgs/logger/struct_logger.go
Normal file
121
backend/pkgs/logger/struct_logger.go
Normal 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)
|
||||
}
|
119
backend/pkgs/logger/struct_logger_test.go
Normal file
119
backend/pkgs/logger/struct_logger_test.go
Normal 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)
|
||||
}
|
51
backend/pkgs/mailer/mailer.go
Normal file
51
backend/pkgs/mailer/mailer.go
Normal 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),
|
||||
)
|
||||
}
|
66
backend/pkgs/mailer/mailer_test.go
Normal file
66
backend/pkgs/mailer/mailer_test.go
Normal 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)
|
||||
}
|
56
backend/pkgs/mailer/message.go
Normal file
56
backend/pkgs/mailer/message.go
Normal 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
|
||||
}
|
26
backend/pkgs/mailer/message_test.go
Normal file
26
backend/pkgs/mailer/message_test.go
Normal 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)
|
||||
}
|
62
backend/pkgs/mailer/templates.go
Normal file
62
backend/pkgs/mailer/templates.go
Normal 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())
|
||||
}
|
444
backend/pkgs/mailer/templates/welcome.html
Normal file
444
backend/pkgs/mailer/templates/welcome.html
Normal 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"
|
||||
>
|
||||
|
||||
</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"
|
||||
>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
7
backend/pkgs/mailer/test-mailer-template.json
Normal file
7
backend/pkgs/mailer/test-mailer-template.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"host": "",
|
||||
"port": 465,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from": ""
|
||||
}
|
7
backend/pkgs/server/constants.go
Normal file
7
backend/pkgs/server/constants.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package server
|
||||
|
||||
const (
|
||||
ContentType = "Content-Type"
|
||||
ContentJSON = "application/json"
|
||||
ContentXML = "application/xml"
|
||||
)
|
48
backend/pkgs/server/request.go
Normal file
48
backend/pkgs/server/request.go
Normal 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)
|
||||
}
|
210
backend/pkgs/server/request_test.go
Normal file
210
backend/pkgs/server/request_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
61
backend/pkgs/server/response.go
Normal file
61
backend/pkgs/server/response.go
Normal 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"))
|
||||
}
|
51
backend/pkgs/server/response_error_builder.go
Normal file
51
backend/pkgs/server/response_error_builder.go
Normal 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))
|
||||
}
|
107
backend/pkgs/server/response_error_builder_test.go
Normal file
107
backend/pkgs/server/response_error_builder_test.go
Normal 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")
|
||||
|
||||
}
|
78
backend/pkgs/server/response_test.go
Normal file
78
backend/pkgs/server/response_test.go
Normal 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}`)
|
||||
|
||||
}
|
27
backend/pkgs/server/result.go
Normal file
27
backend/pkgs/server/result.go
Normal 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
|
||||
}
|
123
backend/pkgs/server/server.go
Normal file
123
backend/pkgs/server/server.go
Normal 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()
|
||||
})
|
||||
}
|
97
backend/pkgs/server/server_test.go
Normal file
97
backend/pkgs/server/server_test.go
Normal 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)
|
||||
}
|
20
backend/pkgs/server/worker.go
Normal file
20
backend/pkgs/server/worker.go
Normal 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()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue