From 9b0d844ec3f3466175d24a71d969ad8d6cb57631 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 29 Mar 2026 20:47:30 +0200 Subject: [PATCH] docs: add releasing process documentation chore: update go.mod dependencies and versions chore: update go.sum with new dependency versions scripts: add coverage check script scripts: add release notes generation script test: refactor tests to use goconvey for assertions test: enhance webserver tests with additional cases and goconvey fix: improve error logging in web server start and stop methods --- .drone.yml | 86 ++++++++---- README.md | 190 ++++++++++++++++++++++++++- config/configfiles.go | 66 +++++++--- docs/RELEASING.md | 115 ++++++++++++++++ go.mod | 13 +- go.sum | 42 +++--- scripts/check-coverage.sh | 32 +++++ scripts/generate-release-notes.sh | 76 +++++++++++ test/configfiles_test.go | 134 +++++++++++-------- test/webserver_test.go | 211 +++++++++++++++++++++++------- web/server.go | 37 +++--- 11 files changed, 795 insertions(+), 207 deletions(-) create mode 100644 docs/RELEASING.md create mode 100755 scripts/check-coverage.sh create mode 100755 scripts/generate-release-notes.sh diff --git a/.drone.yml b/.drone.yml index 604ed9f..0591a35 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2,35 +2,63 @@ kind: pipeline type: docker name: go-lib/micro -steps: -- name: prepare - image: golang:1.20.2 - commands: - - mkdir -p .build - - go get -v ./... - -- name: test - image: golang:1.20.2 - commands: - - go test -v -coverprofile=.build/coverage.txt ./... - depends_on: - - prepare - -- name: vulncheck - image: golang:1.20.2 - commands: - - go install golang.org/x/vuln/cmd/govulncheck@latest - - govulncheck -json ./... > .build/vulncheck.json - depends_on: - - prepare - trigger: event: - - push - - cron - - custom + - push + - tag + ref: + include: + - refs/heads/** + - refs/tags/v* -volumes: -- name: dockersock - host: - path: /var/run/docker.sock +steps: +- name: test + image: golang:1.25.8 + commands: + - go get ./... + - go vet ./... + - mkdir -p .build + - go test -v -coverprofile .build/coverage.out -coverpkg=./config,./web ./... + - go tool cover -func .build/coverage.out | tee .build/coverage.txt + - bash scripts/check-coverage.sh .build/coverage.out 80 + - go install golang.org/x/vuln/cmd/govulncheck@latest + - govulncheck -json ./... > .build/vulncheck.json + +- name: release-notes + image: golang:1.25.8 + commands: + - git fetch --tags --force || true + - bash scripts/generate-release-notes.sh + when: + event: + - tag + status: + - success + +- name: package + image: golang:1.25.8 + commands: + - tar czf .build/sources.tar.gz --exclude=.build --exclude=.git --exclude=.drone.yml . + when: + event: + - tag + status: + - success + +- name: release + image: plugins/gitea-release + settings: + base_url: https://scm.yoorie.de + api_key: + from_secret: gitea_token + files: + - .build/coverage.txt + - .build/sources.tar.gz + - .build/release-notes.md + title: ${DRONE_TAG} + note_from_file: .build/release-notes.md + when: + event: + - tag + status: + - success diff --git a/README.md b/README.md index ee5388e..0ef80ad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,188 @@ -
+# go-lib/micro -# Go micro service library +[![Build Status](https://drone.yoorie.de/api/badges/go-lib/micro/status.svg)](https://drone.yoorie.de/go-lib/micro) +[![Release](https://raster.shields.io/badge/dynamic/json.png?label=release&url=https://scm.yoorie.de/api/v1/repos/go-lib/micro/releases&query=$[0].tag_name)](https://scm.yoorie.de/go-lib/micro/releases) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://scm.yoorie.de/go-lib/micro/src/branch/main/LICENSE) -[![Build Status](https://drone.yoorie.de/api/badges/go-lib/micro/status.svg)](https://drone.yoorie.de/go-lib/micro) [![Release](https://raster.shields.io/badge/dynamic/json.png?label=release&url=https://scm.yoorie.de/api/v1/repos/go-lib/micro/releases&query=$[0].tag_name)](https://scm.yoorie.de/go-lib/micro/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://scm.yoorie.de/go-lib/micro/src/branch/main/LICENSE) +Lightweight Go helper library for building microservices with: -## Documentation +- Opinionated HTTP/HTTPS server bootstrap around chi. +- Built-in health and readiness endpoints. +- Convention-based YAML config loading with defaults. -Is missed so far and will be created soon. +## Installation ---- -Copyright © 2023 yoorie.de +```bash +go get scm.yoorie.de/go-lib/micro +``` + +## Packages + +- [config/configfiles.go](config/configfiles.go): configuration discovery and loading. +- [web/server.go](web/server.go): HTTP/HTTPS server bootstrap and lifecycle. +- [web/health.go](web/health.go): health-check worker and endpoint payloads. + +## Configuration Loading + +The config package supports: + +- Default values via struct tags. +- Loading from an explicit file path. +- Optional convention lookup based on an AppName method. + +Lookup order for convention-based loading: + +1. Executable directory with executable-name.yml +2. Executable directory with executable-name.yaml +3. User-local file in home directory under .myservice/config +4. Global config path returned by util.GetGlobalConfigurationFile(appName, "config") + +Example: + +```go +package main + +import ( + "log" + + "scm.yoorie.de/go-lib/micro/config" +) + +type ServiceConfig struct { + Port int `default:"7080" yaml:"port"` + Host string `default:"0.0.0.0" yaml:"host"` +} + +// Optional: enables convention-based config file discovery. +func (c *ServiceConfig) AppName() string { + return "myservice" +} + +func main() { + cfg := &ServiceConfig{} + + // Option A: explicit file + if err := config.LoadConfigurationFromFile(cfg, "config.yml"); err != nil { + log.Fatal(err) + } + + // Option B: automatic discovery using AppName() + // if err := config.LoadConfiguration(cfg); err != nil { + // log.Fatal(err) + // } +} +``` + +## Web Server + +The web package wraps chi and starts: + +- HTTP server when SslPort is not set. +- HTTPS server and separate HTTP health endpoint server when SslPort is set. + +Default health endpoints: + +- /health/healthz +- /health/readyz + +Example: + +```go +package main + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "scm.yoorie.de/go-lib/micro/web" +) + +type Message struct { + Text string `json:"text"` +} + +func routes() *chi.Mux { + r := chi.NewRouter() + r.Get("/hello", func(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, Message{Text: "hello"}) + }) + return r +} + +func main() { + cfg := &web.WebServerConfiguration{ + Host: "0.0.0.0", + Port: 7080, + } + + server, err := web.NewWebServer(cfg) + if err != nil { + panic(err) + } + + // Optional custom health function. + server.HealthCheck = func() (bool, string) { + return true, "" + } + + server.Mount("/api", routes()) + if err := server.Start(); err != nil { + panic(err) + } + + // Block until interrupt. + server.Join() +} +``` + +## Development + +Run unit tests: + +```bash +go test ./... +``` + +Run vet: + +```bash +go vet ./... +``` + +Run coverage like CI: + +```bash +mkdir -p .build +go test -v -coverprofile .build/coverage.out -coverpkg=./config,./web ./... +go tool cover -func .build/coverage.out +bash scripts/check-coverage.sh .build/coverage.out 80 +``` + +Run vulnerability scan: + +```bash +go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck ./... +``` + +## CI and Release + +Drone pipeline is defined in [.drone.yml](.drone.yml) and includes: + +- Tests +- Coverage gate (80%) +- go vet +- govulncheck +- Tag-based release notes and packaging + +For the complete release workflow see [docs/RELEASING.md](docs/RELEASING.md). + +## Project Docs + +- [docs/DEFINITION_OF_DONE.md](docs/DEFINITION_OF_DONE.md) +- [docs/RELEASING.md](docs/RELEASING.md) + +## License + +MIT, see [LICENSE](LICENSE). diff --git a/config/configfiles.go b/config/configfiles.go index ba5acfe..cd367e7 100644 --- a/config/configfiles.go +++ b/config/configfiles.go @@ -1,9 +1,7 @@ package config import ( - "errors" "fmt" - "io/ioutil" "os" "os/user" "path/filepath" @@ -16,7 +14,10 @@ import ( func GetConfigurationFiles(appName string) []string { // First checking local config - executablePath, _ := os.Executable() + executablePath, executableErr := os.Executable() + if executableErr != nil { + executablePath = os.Args[0] + } fname := filepath.Base(executablePath) fext := filepath.Ext(executablePath) fdir := filepath.Dir(executablePath) @@ -26,8 +27,11 @@ func GetConfigurationFiles(appName string) []string { for _, ext := range ymlExts { fileNames = append(fileNames, filepath.Join(fdir, fnameWoExt+ext)) } - usr, _ := user.Current() - fileNames = append(fileNames, filepath.Join(usr.HomeDir, "."+appName, "config")) + homeDir := "." + if usr, userErr := user.Current(); userErr == nil { + homeDir = usr.HomeDir + } + fileNames = append(fileNames, filepath.Join(homeDir, "."+appName, "config")) // Check for global config fileNames = append(fileNames, util.GetGlobalConfigurationFile(appName, "config")) return fileNames @@ -48,6 +52,33 @@ func LoadConfiguration[T any](config T) error { return LoadConfigurationFromFile(config, "") } +func resolveConfigFile[T any](config T, configFile string) (string, error) { + if configFile != "" { + if !util.FileExists(configFile) { + return "", fmt.Errorf("given configuration file %s cannot be found", configFile) + } + return configFile, nil + } + + appName := getAppNameFromConfig(config) + if appName == "" { + return "", nil + } + return GetConfigurationFile(appName), nil +} + +func getAppNameFromConfig[T any](config T) string { + method := reflect.ValueOf(config).MethodByName("AppName") + if !method.IsValid() { + return "" + } + results := method.Call(nil) + if len(results) == 0 { + return "" + } + return results[0].String() +} + func IsStructPointer[T any](config T) bool { value := reflect.ValueOf(config) return value.Type().Kind() == reflect.Pointer && reflect.Indirect(value).Type().Kind() == reflect.Struct @@ -56,32 +87,25 @@ func IsStructPointer[T any](config T) bool { func LoadConfigurationFromFile[T any](config T, configFile string) error { if !IsStructPointer(config) { genericType := reflect.TypeOf(config) - return errors.New(fmt.Sprintf("Type: %s.%s is not a struct pointer", genericType.PkgPath(), genericType.Name())) + return fmt.Errorf("type: %s.%s is not a struct pointer", genericType.PkgPath(), genericType.Name()) } defaults.Set(config) - if configFile != "" { - if !util.FileExists(configFile) { - return errors.New(fmt.Sprintf("given configuration file %s cannot be found", configFile)) - } - } else { - if method := reflect.ValueOf(config).MethodByName("appName"); !method.IsNil() { - result := method.String() - if result != "" { - configFile = GetConfigurationFile(result) - } - } + + resolvedConfigFile, err := resolveConfigFile(config, configFile) + if err != nil { + return err } - if configFile == "" { + if resolvedConfigFile == "" { return nil } - ymldata, err := ioutil.ReadFile(configFile) + ymldata, err := os.ReadFile(resolvedConfigFile) if err != nil { - return errors.New(fmt.Sprintf("cannot read configuration file %s. %v", configFile, err)) + return fmt.Errorf("cannot read configuration file %s: %w", resolvedConfigFile, err) } err = yaml.Unmarshal(ymldata, config) if err != nil { - return errors.New(fmt.Sprintf("error unmarshalling configuration data: %v", err)) + return fmt.Errorf("error unmarshalling configuration data: %w", err) } return nil } diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..04133b2 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,115 @@ +# Releasing go-lib/micro + +This document describes the process for creating a release of the +`go-lib/micro` library. + +## Overview + +Releases in this project are managed via Git tags. When you push a +tag matching the pattern `v*` (for example, `v0.2.0`), the Drone +pipeline automatically: + +1. Runs quality checks (tests, coverage gate, vet, vulnerability scan) +2. Generates release notes from commits +3. Creates a source archive (`sources.tar.gz`) +4. Publishes a release to Gitea with attached artifacts + +## Versioning + +This library follows semantic versioning: `MAJOR.MINOR.PATCH` +(for example, `v1.2.3`). + +- `v1.0.0`: breaking API changes +- `v1.1.0`: backward-compatible features +- `v1.0.1`: backward-compatible fixes + +## Prerequisites + +Before creating a release, ensure: + +1. Working tree is clean (`git status`) +2. Tests pass locally (`go test ./...`) +3. Coverage is at least 80% +4. Dependencies are tidy (`go mod tidy`) +5. Documentation is up to date (README/docs) + +## Create a Release + +### 1. Prepare (optional) + +Update docs, examples, or changelog if needed: + +```bash +git add README.md docs/ +git commit -m "docs: prepare release" +``` + +### 2. Create tag + +```bash +git tag -a v0.2.0 -m "Release v0.2.0" +``` + +### 3. Push tag + +```bash +git push origin v0.2.0 +``` + +### 4. Verify pipeline and release + +After pushing the tag, verify in Drone and Gitea: + +- Pipeline succeeded for tag build +- Gitea release exists with artifacts: + - `.build/coverage.txt` + - `.build/sources.tar.gz` + - `.build/release-notes.md` + +## Install Released Version + +```bash +# latest +go get scm.yoorie.de/go-lib/micro + +# specific +go get scm.yoorie.de/go-lib/micro@v0.2.0 +``` + +## Troubleshooting + +### Coverage below threshold + +Run locally and inspect uncovered code: + +```bash +go test -v -coverprofile .build/coverage.out ./... +go tool cover -func .build/coverage.out +``` + +### Release step fails + +Common causes: + +- Missing Drone secret `gitea_token` +- Tag does not match `v*` +- Earlier pipeline step failed + +### Wrong tag pushed + +```bash +git tag -d v0.2.0 +git push origin :refs/tags/v0.2.0 +``` + +Then create and push the corrected tag. + +## Release Checklist + +- [ ] Tests pass locally +- [ ] Coverage is at least 80% +- [ ] `go vet ./...` passes +- [ ] `govulncheck ./...` is clean or reviewed +- [ ] Tag `vX.Y.Z` created and pushed +- [ ] Drone pipeline succeeded +- [ ] Gitea release contains expected artifacts diff --git a/go.mod b/go.mod index 3f2b6ba..8f23633 100644 --- a/go.mod +++ b/go.mod @@ -7,22 +7,23 @@ require ( github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/cors v1.2.1 github.com/go-chi/render v1.0.3 - github.com/stretchr/testify v1.8.4 + github.com/smartystreets/goconvey v1.8.1 gopkg.in/yaml.v3 v3.0.1 - scm.yoorie.de/go-lib/certs v0.0.2 - scm.yoorie.de/go-lib/gelf v0.0.2 - scm.yoorie.de/go-lib/util v0.5.0 + scm.yoorie.de/go-lib/certs v0.0.3 + scm.yoorie.de/go-lib/gelf v0.0.3 + scm.yoorie.de/go-lib/util v0.6.0 ) require ( github.com/ajg/form v1.5.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect + github.com/smarty/assertions v1.15.0 // indirect golang.org/x/crypto v0.11.0 // indirect golang.org/x/sys v0.10.0 // indirect gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a // indirect diff --git a/go.sum b/go.sum index bce8802..67d0ab0 100644 --- a/go.sum +++ b/go.sum @@ -5,7 +5,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA= github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -13,9 +12,14 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -32,31 +36,33 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/onsi/gomega v1.27.2 h1:SKU0CXeKE/WVgIV1T61kSa3+IRE8Ekrv9rdXDwwTqnY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a h1:34vqlRjuZiE9c8eHsuZ9nn+GbcimFpvGUEmW+vyfhG8= gopkg.in/aphistic/golf.v0 v0.0.0-20180712155816-02c07f170c5a/go.mod h1:fvTxI2ZW4gO1d+4q4VCKOo+ANBs4gPN3IW00MlCumKc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -65,13 +71,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= -scm.yoorie.de/go-lib/certs v0.0.2 h1:GD+cIMTAGTQ2R/mNtEoJzHTPRxIIfORdB/PmhfKYSKU= -scm.yoorie.de/go-lib/certs v0.0.2/go.mod h1:NMSvuFPENHTtsC9VAPHUAYgPSAKWl/Bh/Z7p+ymbdqg= -scm.yoorie.de/go-lib/gelf v0.0.2 h1:DEvenZpnCzNgXctaECwdJ0hqyH5j02eWQKzEdTriBzs= -scm.yoorie.de/go-lib/gelf v0.0.2/go.mod h1:55m5AWDVkAUJoFVZ0AVYCm5RwucBSV3yObl2jfKdGnw= -scm.yoorie.de/go-lib/util v0.5.0 h1:O5cF/yGPjuE2QYDcjQkPb9SNStEaW0U4RldasKU6Ttw= -scm.yoorie.de/go-lib/util v0.5.0/go.mod h1:FwOw8Q5Bl88u2kiL3BPU3qjdbixfl7g72TJpRh/5HAc= +scm.yoorie.de/go-lib/certs v0.0.3 h1:a7wCAJQj1X+NxwS8r2o49m29oBh1/YvqkUsMQR2GUyk= +scm.yoorie.de/go-lib/certs v0.0.3/go.mod h1:2z4kefIECDXFJrMnRRFO2tax4Udy2WeABkLN3kWgItg= +scm.yoorie.de/go-lib/gelf v0.0.3 h1:3tZQdDQNJisyVQRyY4YfUYwp3dF+s7NlI12noXiiJdg= +scm.yoorie.de/go-lib/gelf v0.0.3/go.mod h1:zZrZ4zGuKFYh8AIeA0nPHWXWHrLoNiKIQ/KrSQhky+s= +scm.yoorie.de/go-lib/util v0.6.0 h1:74Z4vALoMNgsSeETTIC77qTVYoE0QMafsJhCQqw7Y+g= +scm.yoorie.de/go-lib/util v0.6.0/go.mod h1:t9TLwQzr8F1g+SLn9llFbGS0a5hrP+M89IrrOCVpszU= diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..cf7e777 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Check test coverage against minimum threshold. + +set -euo pipefail + +COVERAGE_FILE="${1:-.build/coverage.out}" +MIN_COVERAGE="${2:-80}" + +if [ ! -f "$COVERAGE_FILE" ]; then + echo "Error: coverage file not found: $COVERAGE_FILE" + exit 1 +fi + +# Extract total coverage percentage from go tool cover output. +COVERAGE=$(go tool cover -func "$COVERAGE_FILE" | awk '/^total:/ { match($0, /[0-9.]+%/); print substr($0, RSTART, RLENGTH-1) }') + +if [ -z "$COVERAGE" ]; then + echo "Error: failed to parse coverage from $COVERAGE_FILE" + exit 1 +fi + +echo "Total coverage: ${COVERAGE}%" + +# Compare as integer part to keep shell arithmetic simple and portable. +COVERAGE_INT=${COVERAGE%.*} + +if [ "$COVERAGE_INT" -lt "$MIN_COVERAGE" ]; then + echo "Coverage ${COVERAGE}% is below minimum ${MIN_COVERAGE}%" + exit 1 +fi + +echo "Coverage check passed" diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh new file mode 100755 index 0000000..c21a254 --- /dev/null +++ b/scripts/generate-release-notes.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Generate release notes from commits since the previous tag. + +set -euo pipefail + +CURRENT_TAG="${1:-${DRONE_TAG:-}}" +OUTPUT_FILE="${2:-.build/release-notes.md}" + +if [ -z "$CURRENT_TAG" ]; then + echo "Error: current tag not provided" + exit 1 +fi + +# In CI, the tag name may be available but not present as a local ref. +CURRENT_REF="$CURRENT_TAG" +if ! git rev-parse --verify -q "${CURRENT_REF}^{commit}" >/dev/null; then + CURRENT_REF="${DRONE_COMMIT:-HEAD}" +fi + +PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || echo "") + +mkdir -p "$(dirname "$OUTPUT_FILE")" + +{ + echo "# Release $CURRENT_TAG" + echo + + if [ -n "$PREVIOUS_TAG" ]; then + echo "Changes since $PREVIOUS_TAG:" + echo + + git log "$PREVIOUS_TAG..$CURRENT_REF" --pretty=format:"%s" | while read -r commit; do + if [[ $commit =~ ^([a-z]+)(\(.+\))?:\ (.+)$ ]]; then + TYPE="${BASH_REMATCH[1]}" + SCOPE="${BASH_REMATCH[2]}" + MESSAGE="${BASH_REMATCH[3]}" + + case "$TYPE" in + feat) + echo "- Feature${SCOPE}: $MESSAGE" ;; + fix) + echo "- Fix${SCOPE}: $MESSAGE" ;; + docs) + echo "- Docs${SCOPE}: $MESSAGE" ;; + ci) + echo "- CI${SCOPE}: $MESSAGE" ;; + test) + echo "- Test${SCOPE}: $MESSAGE" ;; + refactor) + echo "- Refactor${SCOPE}: $MESSAGE" ;; + perf) + echo "- Performance${SCOPE}: $MESSAGE" ;; + *) + echo "- $commit" ;; + esac + else + echo "- $commit" + fi + done + else + echo "Initial release" + echo + git log "$CURRENT_REF" --pretty=format:"- %s" | head -20 + fi + + echo + echo "---" + if [ -n "$PREVIOUS_TAG" ]; then + echo "See all changes: https://scm.yoorie.de/git/go-lib/micro/compare/$PREVIOUS_TAG...$CURRENT_TAG" + else + echo "See all changes: https://scm.yoorie.de/git/go-lib/micro/releases/tag/$CURRENT_TAG" + fi +} > "$OUTPUT_FILE" + +echo "Release notes generated: $OUTPUT_FILE" +cat "$OUTPUT_FILE" diff --git a/test/configfiles_test.go b/test/configfiles_test.go index ca8d5af..e5c600f 100644 --- a/test/configfiles_test.go +++ b/test/configfiles_test.go @@ -3,7 +3,7 @@ package test import ( "testing" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" cfg "scm.yoorie.de/go-lib/micro/config" ) @@ -13,70 +13,88 @@ type MyConfig struct { Value2 int `default:"8080" yaml:"value2"` } -func (config *MyConfig) appName() string { +func (config *MyConfig) AppName() string { return "myconfig" } -func TestIsStructPointerStruct(t *testing.T) { - assert.False(t, cfg.IsStructPointer(MyConfig{})) -} -func TestIsStructPointerString(t *testing.T) { - assert.False(t, cfg.IsStructPointer("Bar")) -} - -func TestIsStructPointerSimpleArray(t *testing.T) { - assert.False(t, cfg.IsStructPointer([2]int{292, 2})) -} - -func TestIsStructPointerInt(t *testing.T) { - assert.False(t, cfg.IsStructPointer(12)) -} - -func TestIsStructPointerBool(t *testing.T) { - assert.False(t, cfg.IsStructPointer(false)) -} - -func TestIsStructPointerStructPointer(t *testing.T) { - assert.True(t, cfg.IsStructPointer(&MyConfig{})) -} - -func TestIsStructPointerStructPointerArray(t *testing.T) { - cfg1 := &MyConfig{ - Value: "Foo", - } - cfg2 := &MyConfig{ - Value: "Bar", - } - array := [2]*MyConfig{cfg1, cfg2} - assert.False(t, cfg.IsStructPointer(array)) -} - -func TestLoadConfigurationFromFileWithNoPointerInvalidType(t *testing.T) { - myConfig := MyConfig{} - err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config.yml") - assert.NotNil(t, err) +func TestIsStructPointer(t *testing.T) { + Convey("IsStructPointer", t, func() { + Convey("returns false for struct value", func() { + So(cfg.IsStructPointer(MyConfig{}), ShouldBeFalse) + }) + Convey("returns false for string", func() { + So(cfg.IsStructPointer("Bar"), ShouldBeFalse) + }) + Convey("returns false for simple array", func() { + So(cfg.IsStructPointer([2]int{292, 2}), ShouldBeFalse) + }) + Convey("returns false for int", func() { + So(cfg.IsStructPointer(12), ShouldBeFalse) + }) + Convey("returns false for bool", func() { + So(cfg.IsStructPointer(false), ShouldBeFalse) + }) + Convey("returns true for struct pointer", func() { + So(cfg.IsStructPointer(&MyConfig{}), ShouldBeTrue) + }) + Convey("returns false for struct pointer array", func() { + cfg1 := &MyConfig{Value: "Foo"} + cfg2 := &MyConfig{Value: "Bar"} + array := [2]*MyConfig{cfg1, cfg2} + So(cfg.IsStructPointer(array), ShouldBeFalse) + }) + }) } func TestLoadConfigurationFromFile(t *testing.T) { - myConfig := &MyConfig{} - assert.Equal(t, "myconfig", myConfig.appName()) - cfg.LoadConfigurationFromFile(myConfig, "testdata/config.yml") - assert.Equal(t, "Foo", myConfig.Value) - assert.Equal(t, true, myConfig.Value1) - assert.Equal(t, 8080, myConfig.Value2) -} -func TestLoadConfigurationFromFileNotExist(t *testing.T) { - myConfig := &MyConfig{} - assert.Equal(t, "myconfig", myConfig.appName()) - err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config1.yml") - assert.NotNil(t, err) + Convey("LoadConfigurationFromFile", t, func() { + Convey("returns error for non-pointer type", func() { + err := cfg.LoadConfigurationFromFile(MyConfig{}, "testdata/config.yml") + So(err, ShouldNotBeNil) + }) + Convey("loads config from file successfully", func() { + myConfig := &MyConfig{} + err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config.yml") + So(err, ShouldBeNil) + So(myConfig.Value, ShouldEqual, "Foo") + So(myConfig.Value1, ShouldBeTrue) + So(myConfig.Value2, ShouldEqual, 8080) + }) + Convey("returns error when config file does not exist", func() { + myConfig := &MyConfig{} + err := cfg.LoadConfigurationFromFile(myConfig, "testdata/config1.yml") + So(err, ShouldNotBeNil) + }) + }) } func TestGetConfigurationFiles(t *testing.T) { - fileNames := cfg.GetConfigurationFiles("myapp") - assert.Equal(t, 4, len(fileNames)) - - for _, fileName := range fileNames { - t.Log(fileName) - } + Convey("GetConfigurationFiles returns 4 candidate paths", t, func() { + fileNames := cfg.GetConfigurationFiles("myapp") + So(len(fileNames), ShouldEqual, 4) + }) +} + +func TestGetConfigurationFile(t *testing.T) { + Convey("GetConfigurationFile", t, func() { + Convey("returns empty string when no matching config file exists", func() { + result := cfg.GetConfigurationFile("nonexistentapp_xyz_123") + So(result, ShouldEqual, "") + }) + }) +} + +func TestLoadConfiguration(t *testing.T) { + Convey("LoadConfiguration", t, func() { + Convey("returns error for non-pointer type", func() { + err := cfg.LoadConfiguration(MyConfig{}) + So(err, ShouldNotBeNil) + }) + Convey("returns nil for struct pointer with no config file found", func() { + myConfig := &MyConfig{} + err := cfg.LoadConfiguration(myConfig) + So(err, ShouldBeNil) + So(myConfig.Value, ShouldEqual, "Bar") + }) + }) } diff --git a/test/webserver_test.go b/test/webserver_test.go index afa1b58..dc41867 100644 --- a/test/webserver_test.go +++ b/test/webserver_test.go @@ -5,15 +5,15 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net" "net/http" "os" "testing" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/render" - "github.com/stretchr/testify/assert" + . "github.com/smartystreets/goconvey/convey" "scm.yoorie.de/go-lib/micro/web" "scm.yoorie.de/go-lib/util" ) @@ -24,6 +24,12 @@ var ( sslPort int ) +const ( + readyMessage = "service is ready" + aMessage = "A message" + testFailureMsg = "test failure" +) + type MyData struct { Message string `json:"message"` Count int `json:"count"` @@ -32,7 +38,7 @@ type MyData struct { func MyTestEndpoint(w http.ResponseWriter, r *http.Request) { internalCount++ render.JSON(w, r, &MyData{ - Message: "A message", + Message: aMessage, Count: internalCount, }) } @@ -91,7 +97,9 @@ func TestMain(m *testing.M) { panic(err) } server.Mount("/api", CreateTestRouter()) - server.Start() + if err = server.Start(); err != nil { + panic(err) + } // Allow insecure calls to https http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} @@ -101,56 +109,167 @@ func TestMain(m *testing.M) { os.Exit(exitVal) } +func TestNewWebServerNilConfig(t *testing.T) { + Convey("NewWebServer with nil config returns error", t, func() { + server, err := web.NewWebServer(nil) + So(err, ShouldNotBeNil) + So(server, ShouldBeNil) + }) +} + +func TestGetBindings(t *testing.T) { + Convey("WebServerConfiguration.GetBindings", t, func() { + Convey("returns only http binding when SSL port is 0", func() { + config := &web.WebServerConfiguration{Port: 8080} + So(config.GetBindings(), ShouldEqual, "http://0.0.0.0:8080") + }) + Convey("returns https and http bindings when SSL port is set", func() { + config := &web.WebServerConfiguration{Port: 8080, SslPort: 8443} + So(config.GetBindings(), ShouldEqual, "https://0.0.0.0:8443,http://0.0.0.0:8080") + }) + }) +} + +func TestBuildHostAddress(t *testing.T) { + Convey("WebServerConfiguration.BuildHostAddress", t, func() { + Convey("uses 0.0.0.0 when host is empty", func() { + config := &web.WebServerConfiguration{Port: 8080, SslPort: 8443} + So(config.BuildHostAddress(false), ShouldEqual, "0.0.0.0:8080") + So(config.BuildHostAddress(true), ShouldEqual, "0.0.0.0:8443") + }) + Convey("uses configured host when set", func() { + config := &web.WebServerConfiguration{Host: "192.168.1.1", Port: 8080, SslPort: 8443} + So(config.BuildHostAddress(false), ShouldEqual, "192.168.1.1:8080") + So(config.BuildHostAddress(true), ShouldEqual, "192.168.1.1:8443") + }) + }) +} + +func TestStartWithNoMounts(t *testing.T) { + Convey("Start returns error when no mounts are registered", t, func() { + port, err := GetFreePort() + So(err, ShouldBeNil) + + config := &web.WebServerConfiguration{Port: port} + server, err := web.NewWebServer(config) + So(err, ShouldBeNil) + + err = server.Start() + So(err, ShouldNotBeNil) + }) +} + +func TestNonSSLServer(t *testing.T) { + Convey("Non-SSL web server starts, serves requests, and stops", t, func() { + port, err := GetFreePort() + So(err, ShouldBeNil) + + config := &web.WebServerConfiguration{Port: port} + server, err := web.NewWebServer(config) + So(err, ShouldBeNil) + + server.Mount("/api", CreateTestRouter()) + err = server.Start() + So(err, ShouldBeNil) + + time.Sleep(100 * time.Millisecond) + + uri := fmt.Sprintf("http://localhost:%d/health/readyz", port) + resp, err := http.Get(uri) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + + body, _ := io.ReadAll(resp.Body) + healthData := web.HealthData{} + err = json.Unmarshal(body, &healthData) + So(err, ShouldBeNil) + So(healthData.Message, ShouldEqual, readyMessage) + + server.Stop() + }) +} + +func TestUnhealthyServer(t *testing.T) { + Convey("Server with failing HealthCheck returns 503 from healthz endpoint", t, func() { + port, err := GetFreePort() + So(err, ShouldBeNil) + + config := &web.WebServerConfiguration{Port: port} + server, err := web.NewWebServer(config) + So(err, ShouldBeNil) + + server.HealthCheck = func() (bool, string) { return false, testFailureMsg } + server.Mount("/api", CreateTestRouter()) + err = server.Start() + So(err, ShouldBeNil) + + time.Sleep(100 * time.Millisecond) + + uri := fmt.Sprintf("http://localhost:%d/health/healthz", port) + resp, err := http.Get(uri) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusServiceUnavailable) + + server.Stop() + }) +} + func TestReady(t *testing.T) { - uri := getServerURL(false, "/readyz") - resp, err := http.Get(uri) - assert.Nil(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - body, _ := io.ReadAll(resp.Body) - healthData := web.HealthData{} - err = json.Unmarshal(body, &healthData) - assert.Nil(t, err) - assert.Equal(t, "service is ready", healthData.Message) - assert.NotEmpty(t, healthData.LastChecked) + Convey("HTTP ready endpoint returns 200 with ready message", t, func() { + uri := getServerURL(false, "/readyz") + resp, err := http.Get(uri) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + body, _ := io.ReadAll(resp.Body) + healthData := web.HealthData{} + err = json.Unmarshal(body, &healthData) + So(err, ShouldBeNil) + So(healthData.Message, ShouldEqual, readyMessage) + So(healthData.LastChecked, ShouldNotBeZeroValue) + }) } func TestReadySSL(t *testing.T) { - uri := getServerURL(true, "/health/readyz") - resp, err := http.Get(uri) - assert.Nil(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - body, _ := io.ReadAll(resp.Body) - healthData := web.HealthData{} - err = json.Unmarshal(body, &healthData) - assert.Nil(t, err) - assert.Equal(t, "service is ready", healthData.Message) - assert.NotEmpty(t, healthData.LastChecked) + Convey("HTTPS ready endpoint returns 200 with ready message", t, func() { + uri := getServerURL(true, "/health/readyz") + resp, err := http.Get(uri) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + body, _ := io.ReadAll(resp.Body) + healthData := web.HealthData{} + err = json.Unmarshal(body, &healthData) + So(err, ShouldBeNil) + So(healthData.Message, ShouldEqual, readyMessage) + So(healthData.LastChecked, ShouldNotBeZeroValue) + }) } func TestHealthy(t *testing.T) { - resp, err := http.Get(getServerURL(false, "/healthz")) - assert.Nil(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - body, _ := io.ReadAll(resp.Body) - healthData := web.HealthData{} - err = json.Unmarshal(body, &healthData) - assert.Nil(t, err) - assert.Equal(t, "service up and running", healthData.Message) - assert.NotEmpty(t, healthData.LastChecked) + Convey("Healthy endpoint returns 200 with running message", t, func() { + resp, err := http.Get(getServerURL(false, "/healthz")) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + body, _ := io.ReadAll(resp.Body) + healthData := web.HealthData{} + err = json.Unmarshal(body, &healthData) + So(err, ShouldBeNil) + So(healthData.Message, ShouldEqual, "service up and running") + So(healthData.LastChecked, ShouldNotBeZeroValue) + }) } func TestSslEndpoint(t *testing.T) { - uri := getServerURL(true, "/api/myendpoint") - resp, err := http.Get(uri) - assert.Nil(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - body, err := ioutil.ReadAll(resp.Body) - assert.Nil(t, err) - t.Logf("Body: %s", string(body)) - myData := MyData{} - err = json.Unmarshal(body, &myData) - assert.Nil(t, err) - assert.NotNil(t, myData) - assert.Equal(t, "A message", myData.Message) - assert.Greater(t, myData.Count, 0) + Convey("SSL endpoint returns 200 with expected data", t, func() { + uri := getServerURL(true, "/api/myendpoint") + resp, err := http.Get(uri) + So(err, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + body, err := io.ReadAll(resp.Body) + So(err, ShouldBeNil) + myData := MyData{} + err = json.Unmarshal(body, &myData) + So(err, ShouldBeNil) + So(myData.Message, ShouldEqual, aMessage) + So(myData.Count, ShouldBeGreaterThan, 0) + }) } diff --git a/web/server.go b/web/server.go index 3f1a9a0..0a0ea4b 100644 --- a/web/server.go +++ b/web/server.go @@ -22,6 +22,10 @@ import ( log "scm.yoorie.de/go-lib/gelf" ) +const ( + errStartingServerLog = "error starting server: %s" +) + type WebServerConfiguration struct { Host string `yaml:"host"` Port int `default:"7080" yaml:"port"` @@ -202,26 +206,13 @@ func (server *WebServer) Start() error { server.DebugRoutes("Main", server.router) server.DebugRoutes("Health", server.healthRouter) if ssl { - err := server.setupSsl() - if err != nil { + if err := server.setupSsl(); err != nil { return err } } else { - // own http server for the healthchecks - httpAddress := server.serviceConfig.BuildHostAddress(false) - server.srv = &http.Server{ - Addr: httpAddress, - WriteTimeout: time.Second * 15, - ReadTimeout: time.Second * 15, - IdleTimeout: time.Second * 60, - Handler: server.router, + if err := server.setupHttp(); err != nil { + return err } - go func() { - log.Infof("Starting http server on address: %s", server.srv.Addr) - if err := server.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Alertf("error starting server: %s", err.Error()) - } - }() } return nil } @@ -239,7 +230,7 @@ func (server *WebServer) setupHttp() error { go func() { log.Infof("Starting http server on address: %s", server.srv.Addr) if err := server.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Alertf("error starting server: %s", err.Error()) + log.Alertf(errStartingServerLog, err.Error()) } }() return nil @@ -263,7 +254,7 @@ func (server *WebServer) setupSsl() error { go func() { log.Infof("Starting https server on address: %s", server.sslsrv.Addr) if err := server.sslsrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { - log.Alertf("error starting server: %s", err.Error()) + log.Alertf(errStartingServerLog, err.Error()) } }() httpAddress := server.serviceConfig.BuildHostAddress(false) @@ -277,7 +268,7 @@ func (server *WebServer) setupSsl() error { go func() { log.Infof("Starting http server on address: %s", server.srv.Addr) if err := server.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Alertf("error starting server: %s", err.Error()) + log.Alertf(errStartingServerLog, err.Error()) } }() return nil @@ -296,9 +287,13 @@ func (server *WebServer) Stop() { defer cancel() log.Info("Shutting down server ...") - server.srv.Shutdown(ctx) + if err := server.srv.Shutdown(ctx); err != nil { + log.Alertf("error shutting down http server: %s", err.Error()) + } if server.isSsl() { - server.sslsrv.Shutdown(ctx) + if err := server.sslsrv.Shutdown(ctx); err != nil { + log.Alertf("error shutting down https server: %s", err.Error()) + } } log.Info("Server has been shutted down") }