Initial commit
All checks were successful
golangci-lint / lint (push) Successful in 19m8s

This commit is contained in:
Valentin Brandl 2024-08-25 16:40:48 +02:00
commit 12fbb06666
Signed by: vbrandl
GPG Key ID: CAD4DA1A789125F9
20 changed files with 612 additions and 0 deletions

View File

@ -0,0 +1,20 @@
name: golangci-lint
on:
push:
branches:
- main
pull_request:
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: 'latest'

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# End of https://www.toptal.com/developers/gitignore/api/go
config.toml
server

9
assets/embed.go Normal file
View File

@ -0,0 +1,9 @@
package assets
import "embed"
//go:embed public
var Templates embed.FS
//go:embed static
var Static embed.FS

View File

@ -0,0 +1,13 @@
{{ define "footer" }}
<div class="footbar">
<div class="container">
<div class="row">
<footer class="footer full-width">
<p class="mtop">
<a href="#" target="_blank"></a>
</p>
</footer>
</div>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./static/js/htmx.org@2.0.2.js"></script>
<!-- <link href="css/style.css" rel="stylesheet"> -->
<title>Hello, World!</title>
</head>
<body>
{{ template "navbar" }}
<div class="container">
<div class="row">
<p>Content</p>
</div>
</div>
{{ template "footer" }}
</body>
</html>

View File

@ -0,0 +1,14 @@
{{ define "navbar" }}
<div class="navbar">
<div class="container">
<div class="row">
<div class="navigation">
<a href="/">Logo</a>
<ul class="navigation-items">
<li class="navigation-item"><a class="navigation-link" href="/">Home</a></li>
</ul>
</div>
</div>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./static/js/htmx.org@2.0.2.js"></script>
<!-- <link href="css/style.css" rel="stylesheet"> -->
<title>Hello, World!</title>
</head>
<body>
{{ template "navbar" }}
<div class="container">
<div class="row">
<h2>Not Found</h2>
<p>Page not Found</p>
<p>Request-ID: {{ .RequestID }}</p>
</div>
</div>
{{ template "footer" }}
</body>
</html>

File diff suppressed because one or more lines are too long

79
common/config.go Normal file
View File

@ -0,0 +1,79 @@
package common
import (
"os"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/rs/zerolog"
l "github.com/rs/zerolog/log"
)
const (
ConfigPath = "./config.toml"
DefaultLogLevel = "info"
DefaultPort = 8080
)
type Config struct {
Log log
Http http
}
type http struct {
Port uint16
}
type log struct {
Level string
}
func defaultConfig() Config {
return Config{
Log: log{
Level: DefaultLogLevel,
},
Http: http{
Port: DefaultPort,
},
}
}
func loadConfig(path string) Config {
logger := l.Error().Str("path", path)
config := defaultConfig()
file, err := os.Open(path)
if err != nil {
logger.Err(err).Msg("cannot open file, using default config")
return config
}
dec := toml.NewDecoder(file)
err = dec.Decode(&config)
if err != nil {
logger.Err(err).Msg("cannot parse config, using default")
return config
}
return config
}
func NewConfig() Config {
return loadConfig(ConfigPath)
}
func (c *Config) LogLevel() zerolog.Level {
switch strings.ToLower(c.Log.Level) {
case "error":
return zerolog.ErrorLevel
case "debug":
return zerolog.DebugLevel
case "trace":
return zerolog.TraceLevel
case "warn":
return zerolog.WarnLevel
default:
return zerolog.InfoLevel
}
}

4
example.config.toml Normal file
View File

@ -0,0 +1,4 @@
[http]
port = 8080
[log]
level = "info"

14
go.mod Normal file
View File

@ -0,0 +1,14 @@
module git.vbrandl.net/vbrandl/go-web-template
go 1.22.6
require (
github.com/gorilla/mux v1.8.1 // indirect
github.com/justinas/alice v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
golang.org/x/sys v0.12.0 // indirect
)

22
go.sum Normal file
View File

@ -0,0 +1,22 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

77
handlers/healthcheck.go Normal file
View File

@ -0,0 +1,77 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"time"
"github.com/rs/zerolog/hlog"
)
const (
jsonContentType = "application/json;charset=utf-8"
)
type Timing struct {
TimeMillis time.Duration `json:"timeMillis"`
Source string `json:"source"`
}
type HealthCheckResult struct {
Success bool `json:"success"`
Messages []string `json:"messages"`
Time time.Time `json:"time"`
Timing []Timing `json:"timing"`
Response HealthCheckResponse `json:"response"`
}
type HealthCheckResponse struct {
IpAddress string `json:"ipAddress"`
MemUsage string `json:"memUsage"`
}
func (app *Application) HealthCheck(w http.ResponseWriter, r *http.Request) {
l := hlog.FromRequest(r)
l.Info().Msg("HealthCheck")
start := time.Now()
t, _ := json.Marshal(HealthCheckResult{
Success: true,
Messages: []string{},
Time: time.Now().UTC(),
Timing: []Timing{
{
Source: "HealthCheck",
TimeMillis: time.Since(start),
},
},
Response: HealthCheckResponse{
IpAddress: getIP(r),
MemUsage: memUsage(),
},
})
w.Header().Set("content-type", jsonContentType)
_, _ = fmt.Fprint(w, string(t))
}
func getIP(r *http.Request) string {
// forwarded := r.Header.Get("X-FORWARDED-FOR")
// if forwarded != "" {
// return forwarded
// }
return r.RemoteAddr
}
func memUsage() string {
var m runtime.MemStats
runtime.ReadMemStats(&m)
result := fmt.Sprintf("memoryusage::Alloc = %v MB::TotalAlloc = %v MB::Sys = %v MB::tNumGC = %v", bToMb(m.Alloc), bToMb(m.TotalAlloc), bToMb(m.Sys), m.NumGC)
return result
}
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}

22
handlers/index.go Normal file
View File

@ -0,0 +1,22 @@
package handlers
import (
"net/http"
"github.com/rs/zerolog/hlog"
)
func (app *Application) Index(w http.ResponseWriter, r *http.Request) {
l := hlog.FromRequest(r)
l.Info().Msg("Index")
reqId := RequestID(r)
err := indexTemplate.Execute(w, templateData{
RequestID: reqId,
})
if err != nil {
l.Error().Err(err).Msg("error executing template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

48
handlers/index_test.go Normal file
View File

@ -0,0 +1,48 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"git.vbrandl.net/vbrandl/go-web-template/assets"
"git.vbrandl.net/vbrandl/go-web-template/common"
"git.vbrandl.net/vbrandl/go-web-template/handlers"
"git.vbrandl.net/vbrandl/go-web-template/service"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func PrepareApplication() handlers.Application {
config := common.NewConfig()
ser := service.New(config)
zerolog.SetGlobalLevel(config.LogLevel())
app, err := handlers.NewApplication(ser, assets.Static)
if err != nil {
log.Fatal().Err(err).Msg("failed creating application")
}
return app
}
func TestIndex(t *testing.T) {
app := PrepareApplication()
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
app.Handler().ServeHTTP(w, req)
// app.Index(w, req)
res := w.Result()
defer res.Body.Close()
if res.StatusCode != 200 {
t.Errorf("expected status to be 200, got %d", res.StatusCode)
}
reqId := res.Header.Get("x-request-id")
if reqId == "" {
t.Errorf("expected `x-request-id` header to be present, got %s", reqId)
}
}

23
handlers/not_found.go Normal file
View File

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
"github.com/rs/zerolog/hlog"
)
func (app *Application) NotFound(w http.ResponseWriter, r *http.Request) {
l := hlog.FromRequest(r)
l.Info().Msg("Not found")
reqId := RequestID(r)
w.WriteHeader(http.StatusNotFound)
err := notFoundTemplate.Execute(w, templateData{
RequestID: reqId,
})
if err != nil {
l.Error().Err(err).Msg("error executing template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View File

@ -0,0 +1,37 @@
package handlers_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNotFound(t *testing.T) {
app := PrepareApplication()
req := httptest.NewRequest(http.MethodGet, "/some/invalid/path", nil)
w := httptest.NewRecorder()
app.Handler().ServeHTTP(w, req)
res := w.Result()
defer res.Body.Close()
if res.StatusCode != 404 {
t.Errorf("expected status to be 404, got %d", res.StatusCode)
}
reqId := res.Header.Get("x-request-id")
if reqId == "" {
t.Errorf("expected `x-request-id` header to be present, got %s", reqId)
}
data, err := io.ReadAll(res.Body)
if err != nil {
t.Errorf("expected error to be nil, got %v", err)
}
if !strings.Contains(string(data), reqId) {
t.Errorf("expected body to contain request ID but it does not")
}
}

79
handlers/route.go Normal file
View File

@ -0,0 +1,79 @@
package handlers
import (
"io/fs"
"net/http"
"time"
"git.vbrandl.net/vbrandl/go-web-template/service"
"github.com/gorilla/mux"
"github.com/justinas/alice"
"github.com/rs/zerolog/hlog"
"github.com/rs/zerolog/log"
)
type Application struct {
Service *service.Service
StaticFiles fs.FS
}
func NewApplication(service *service.Service, staticFiles fs.FS) (Application, error) {
application := Application{
Service: service,
StaticFiles: staticFiles,
}
err := application.ParseTemplates()
return application, err
}
func (app *Application) Routes() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/", app.Index).Methods("GET")
router.HandleFunc("/health", app.HealthCheck).Methods("GET")
// strip off the location such that we route correctly
staticContent, err := fs.Sub(app.StaticFiles, "static")
if err != nil {
log.Fatal().Err(err).Msg("error with static content")
}
f := http.FileServerFS(staticContent)
router.PathPrefix("/static/").Handler(http.StripPrefix("/static", f))
router.NotFoundHandler = http.HandlerFunc(app.NotFound)
return router
}
func (app *Application) Handler() http.Handler {
logger := log.Logger
middlewares := alice.New().
Append(hlog.NewHandler(logger)).
Append(hlog.AccessHandler(func(r *http.Request, status int, size int, duration time.Duration) {
hlog.FromRequest(r).Info().
Str("method", r.Method).
Stringer("url", r.URL).
Int("status", status).
Int("size", size).
Str("remote-addr", r.RemoteAddr).
Dur("duration", duration).
Str("user-agent", r.UserAgent()).
Msg("")
})).
Append(hlog.RequestIDHandler("request-id", "x-request-id"))
routes := app.Routes()
return middlewares.Then(routes)
}
func RequestID(r *http.Request) string {
if id, ok := hlog.IDFromRequest(r); ok {
return id.String()
} else {
l := hlog.FromRequest(r)
l.Warn().Msg("No associated request ID")
return "unknown"
}
}

30
handlers/template.go Normal file
View File

@ -0,0 +1,30 @@
package handlers
import (
"html/template"
"git.vbrandl.net/vbrandl/go-web-template/assets"
)
var indexTemplate *template.Template
var notFoundTemplate *template.Template
type templateData struct {
RequestID string
}
func (app *Application) ParseTemplates() error {
t, err := template.ParseFS(assets.Templates, "public/html/index.html", "public/html/navbar.tmpl", "public/html/footer.tmpl")
if err != nil {
return err
}
indexTemplate = t
t, err = template.ParseFS(assets.Templates, "public/html/notFound.html", "public/html/navbar.tmpl", "public/html/footer.tmpl")
if err != nil {
return err
}
notFoundTemplate = t
return nil
}

36
service/service.go Normal file
View File

@ -0,0 +1,36 @@
package service
import (
"sync"
"time"
"git.vbrandl.net/vbrandl/go-web-template/common"
)
type Service struct {
BackgroundJobsStarted bool
ServiceMutex sync.Mutex
StartTime time.Time
Config common.Config
}
func New(config common.Config) *Service {
ser := &Service{
BackgroundJobsStarted: false,
ServiceMutex: sync.Mutex{},
StartTime: time.Now(),
Config: config,
}
ser.StartBackground()
return ser
}
func (ser *Service) StartBackground() {
ser.ServiceMutex.Lock()
defer ser.ServiceMutex.Unlock()
ser.BackgroundJobsStarted = true
// TODO: start jobs
}