Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • fb6-wp11-devops/webservice
  • maha1056/devops-pipeline
  • ludo8147/webservice
  • s52888/webservice
  • masi9606/webservice
  • kibu5600/webservice
  • s78689/webservice
  • s50860/webservice
  • s92604/devops-webservice
  • s76867/webservice-devops
  • s92274/webservice
  • s80066/webservice
  • masa1998/webservice
  • s91190/app-service
  • s84985/webservice
  • gjahn/webservice-ws-2425
  • s75359/webservice
  • ouch4861/webservice-ws-24-oc
  • s92274/webservice-msws-24
  • ewbo4360/webservice
20 results
Show changes
Commits on Source (35)
workflow:
rules:
- if: >-
$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"
when: 'always'
- when: 'never'
job_trigger-pipeline:
trigger:
project: 'fb6-wp11-devops/webservice-build-and-publish'
......@@ -14,7 +14,7 @@ export PATH := $(BIN_DIR):$(PATH)
export GOMODCACHE = $(LOCAL_DIR)/cache/go
export GOTMPDIR = $(TMP_DIR)/go
export GOBIN = $(BIN_DIR)
default: clean install build run
......@@ -37,13 +37,22 @@ $(BIN_DIR)/artifact.bin:
cd $(SRC_DIR) \
&& go build \
-o $(@) \
-ldflags "-X webservice/configuration.version=0.0.1" \
$(SRC_DIR)/*.go
.PHONY: build-linux
build-linux: export GOOS := linux
build-linux: export GOARCH := amd64
build-linux: export CGO_ENABLED := 0
build-linux: $(BIN_DIR)/artifact.bin
sha256sum $(BIN_DIR)/artifact.bin
.PHONY: test
.SILENT: test
test:
cd $(SRC_DIR) \
&& go clean -testcache \
&& go test \
-race \
-v \
......
Webservice
==========
A Go-based simple web service meant to be the subject of any exercise
or even used in the project work.
#### Prerequisites:
* minimal [required version](./go.mod#L3) of the Go toolchain (install via
system package manager or [by hand](https://go.dev/doc/install))
* [optional] [Redis](https://redis.io/docs/install/) to persist state
#### State:
If the database host is not explicitly defined, then the state is ephemeral. For more
information checkout the [configuration code](./configuration/config.go).
#### Build:
1. Install dependencies: `go get -t ./...`
2. Run locally: `go run .`
3. Execute unit tests: `go test -race -v ./...`
4. Build artifact: `go build -o ./artifact.bin ./*.go`
To build for another platform, set `GOOS` and `GOARCH`. To yield a static
binary (fully self-contained, no dynamic linking) set `CGO_ENABLED=0`.
To set a version during build time, add the following CLI option
`-ldflags "-X webservice/configuration.version=${VERSION}"`.
For more information, please refer to the [Makefile](./Makefile).
#### Run:
```bash
HOST=0.0.0.0 PORT=8080 ./artifact.bin
```
#### Interact:
##### Landing page
plain text:
```bash
curl http://localhost:8080
```
HTML:
```bash
curl --header 'Accept: text/html; charset=utf-8' http://localhost:8080
# or just open in a browser
```
##### Health check
```bash
curl http://localhost:8080/health
```
##### Server side environment variables
List environment variables visible by the webservice process if environment
is not `production`.
```bash
curl http://localhost:8080/env
```
##### State life cycle
URL slug is used as identifier and the body is the actual *data* being stored.
Please note, when writing (add or change) something, `Content-Type` must be set
in the request header.
Write an entry:
```bash
curl \
-X PUT \
--header 'Content-Type: text/plain; charset=utf-8' \
--data 'foo' \
http://localhost:8080/state/bar
```
Find out MIME type and size of an entry:
```bash
curl \
-X HEAD \
http://localhost:8080/state/bar
```
Obtain an entry:
```bash
curl \
-X GET \
http://localhost:8080/state/bar
```
Remove an entry:
```bash
curl \
-X DELETE \
--verbose \
http://localhost:8080/state/bar
```
List all existing entries (returns JSON or plain text, depending on the `Accept` header):
```bash
curl \
-X GET \
--header 'Accept: text/plain' \
http://localhost:8080/states
```
Upload an entire file:
```bash
curl \
-X PUT \
--header 'Content-Type: application/pdf' \
--upload-file ./example.pdf \
http://localhost:8080/state/pdf-doc
```
Download a file:
```bash
curl \
-X GET \
--output ./example-copy.pdf \
http://localhost:8080/state/pdf-doc
```
......@@ -3,20 +3,43 @@ package configuration
import (
"errors"
"fmt"
"os"
"log/slog"
"unicode"
fp "path/filepath"
configParser "github.com/caarlos0/env/v9"
)
const BODY_SIZE_LIMIT = 32 * 1024 * 1024 // 32 MB, in bytes
var version string = "n/a"
type Config struct {
Environment string `env:"ENV_NAME" envDefault:"development"`
Host string `env:"HOST" envDefault:"127.0.0.1"`
Port int16 `env:"PORT" envDefault:"3000"`
Version string
FontColor string `env:"FONT_COLOR" envDefault:""`
LogLevel string `env:"LOG_LEVE" envDefault:"error"`
Environment string `env:"ENV_NAME" envDefault:"development"`
Host string `env:"HOST" envDefault:"127.0.0.1"`
Port int16 `env:"PORT" envDefault:"3000"`
DatabaseHost string `env:"DB_HOST" envDefault:""`
DatabasePort int16 `env:"DB_PORT" envDefault:"6379"`
DatabaseName int `env:"DB_NAME" envDefault:"0"`
DatabaseUsername string `env:"DB_USERNAME" envDefault:""`
DatabasePassword string `env:"DB_PASSWORD" envDefault:""`
}
func New() ( *Config, error ){
cfg := Config{}
cfg := Config{
Version: version,
}
if err := configParser.Parse( &cfg ); err != nil {
return nil, err
......@@ -33,5 +56,64 @@ func New() ( *Config, error ){
)
}
if cfg.Environment == "development" { cfg.LogLevel = "debug" }
if _, err := cfg.GetLogLevel(); err != nil {
return nil, err
}
if len( cfg.DatabaseHost ) >= 1 && len( cfg.DatabasePassword ) >= 2 {
if ! fp.IsLocal( cfg.DatabasePassword ) && ! fp.IsAbs( cfg.DatabasePassword ) {
return nil, errors.New(
fmt.Sprintln( "Database password must be a file path" ),
)
}
_, err := os.Stat( cfg.DatabasePassword )
if err != nil {
if errors.Is( err, os.ErrNotExist ){
return nil, errors.New(
fmt.Sprintln( "Database password file does not exist" ),
)
}
return nil, errors.New(
fmt.Sprintln( "Database password file not accessible" ),
)
}
}
if len( cfg.FontColor ) >= 1 {
if len( cfg.FontColor ) >= 21 {
return nil, errors.New(
fmt.Sprintln( "Font color too long" ),
)
}
for _, r := range cfg.FontColor {
if ! unicode.IsLetter( r ) {
return nil, errors.New(
fmt.Sprintln( "Invalid character in font color" ),
)
}
}
}
return &cfg, nil
}
func ( cfg *Config ) GetLogLevel() ( slog.Level, error ){
possibleLogLevels := map[ string ] slog.Level {
"error": slog.LevelError,
"debug": slog.LevelDebug,
}
level, ok := possibleLogLevels[ cfg.LogLevel ]
if !ok {
return slog.LevelError, errors.New(
fmt.Sprintf( "Invalid log level: %s", cfg.LogLevel ),
)
}else{
return level, nil
}
}
\ No newline at end of file
......@@ -5,31 +5,34 @@ go 1.21
require (
github.com/caarlos0/env/v9 v9.0.0
github.com/go-playground/validator/v10 v10.15.5
github.com/gofiber/fiber/v2 v2.49.2
github.com/gofiber/fiber/v2 v2.51.0
github.com/redis/go-redis/v9 v9.3.0
github.com/stretchr/testify v1.8.4
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.49.0 // indirect
github.com/valyala/fasthttp v1.50.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
......@@ -2,17 +2,25 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc=
github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/gofiber/fiber/v2 v2.49.2 h1:ONEN3/Vc+dUCxxDgZZwpqvhISgHqb+bu+isBiEyKEQs=
github.com/gofiber/fiber/v2 v2.49.2/go.mod h1:gNsKnyrmfEWFpJxQAV0qvW6l70K1dZGno12oLtukcts=
github.com/gofiber/fiber/v2 v2.51.0 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ=
github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
......@@ -21,10 +29,14 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
......@@ -39,6 +51,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.49.0 h1:9FdvCpmxB74LH4dPb7IJ1cOSsluR07XG3I1txXWwJpE=
github.com/valyala/fasthttp v1.49.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M=
github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
......@@ -47,6 +61,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
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=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
......
......@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
......@@ -11,6 +12,7 @@ import (
"webservice/configuration"
"webservice/routing"
"webservice/state"
"github.com/gofiber/fiber/v2"
)
......@@ -19,22 +21,48 @@ import (
func main() {
config, err := configuration.New()
if err != nil {
log.Fatalf( "HTTP server failed to start: %v", err )
slog.Error( fmt.Sprintf( "HTTP server failed to start: %v", err ) )
os.Exit( 1 )
}
level, _ := config.GetLogLevel()
slog.SetDefault(
slog.New(
slog.NewTextHandler(
os.Stdout,
&slog.HandlerOptions{
Level: level,
},
),
),
)
server := fiber.New( fiber.Config{
AppName: "webservice",
DisableStartupMessage: config.Environment != "development",
BodyLimit: configuration.BODY_SIZE_LIMIT,
})
var store state.Store
if len( config.DatabaseHost ) <= 0 {
store = state.NewEphemeralStore()
} else {
store = state.NewPersistentStore( config )
}
var isHealthy = false
routing.SetRoutes( server, config, &isHealthy )
err = routing.SetRoutes( server, config, store, &isHealthy )
if err != nil {
slog.Error( fmt.Sprintf( "HTTP server failed to start: %v", err ) )
os.Exit( 1 )
}
go func(){
err := server.Listen( fmt.Sprintf( "%s:%d", config.Host, config.Port ) )
if err != nil {
log.Fatalf( "HTTP server failed to start: %v", err )
slog.Error( fmt.Sprintf( "HTTP server failed to start: %v", err ) )
os.Exit( 1 )
}
}()
......@@ -65,6 +93,10 @@ func main() {
if err != nil {
log.Printf( "HTTP server failed to shut down: %v", err )
}
err = store.Disconnect()
if err != nil {
log.Printf( "Store failed to disconnect: %v", err )
}
concludeShutdown()
case <-shuttingDown.Done():
......
......@@ -7,33 +7,55 @@ import (
"strings"
"net/http"
"html/template"
"log"
log "log/slog"
"bytes"
"mime"
"webservice/configuration"
"webservice/state"
f "github.com/gofiber/fiber/v2"
)
func SetRoutes( router *f.App, config *configuration.Config, healthiness *bool ){
func SetRoutes( router *f.App, config *configuration.Config, store state.Store, healthiness *bool ) error {
indexHtmlTemplate, err := template.New( "index" ).Parse( indexHtml )
if err != nil {
log.Fatal( err )
return err
}
metricsTextTemplate, err := template.New( "metrics" ).Parse( metricsText )
if err != nil {
return err
}
if config.LogLevel == "debug" {
router.All( "*", func( c *f.Ctx ) error {
log.Debug(
fmt.Sprintf( "%s %s mime:%s agent:%s",
c.Method(),
c.Path(),
c.Get( f.HeaderContentType, c.Get( f.HeaderAccept, "" ) ),
c.Get( f.HeaderUserAgent ),
),
)
return c.Next()
})
}
router.Get( "/", func( c *f.Ctx ) error {
headers := c.GetReqHeaders()
if ! strings.Contains( headers[ "Accept" ], "html" ) {
acceptHeader := strings.Join( headers[ "Accept" ], " " )
if ! strings.Contains( acceptHeader , "html" ) {
c.Set( "Content-Type", "text/plain; charset=utf-8" )
return c.SendString( "Hello, World!" )
}
data := indexHtmlData{
Version: "",
Color: "",
Version: config.Version,
Color: config.FontColor,
}
buffer := &bytes.Buffer{}
......@@ -75,6 +97,36 @@ func SetRoutes( router *f.App, config *configuration.Config, healthiness *bool )
})
router.Get( "/metrics", func( c *f.Ctx ) error {
headers := c.GetReqHeaders()
acceptHeader := strings.Join( headers[ "Accept" ], " " )
buffer := &bytes.Buffer{}
if strings.Contains( acceptHeader , "json" ) {
// FUTUREWORK: implement https://opentelemetry.io/docs/specs/otlp/#otlphttp
return c.SendStatus( http.StatusNotAcceptable )
} else {
names, err := store.List()
if err != nil {
log.Debug( err.Error() )
return c.SendStatus( http.StatusInternalServerError )
}
data := metricsTextData{
Count: len( names ),
}
err = metricsTextTemplate.Execute( buffer, data )
if err != nil {
return err
}
c.Set( "Content-Type", "text/plain; charset=utf-8" )
return c.Send( buffer.Bytes() )
}
})
router.Get( "/env", func( c *f.Ctx ) error {
c.Type( "txt", "utf-8" )
......@@ -86,6 +138,7 @@ func SetRoutes( router *f.App, config *configuration.Config, healthiness *bool )
for _, envVar := range os.Environ() {
_, err := c.WriteString( fmt.Sprintln( envVar ) )
if err != nil {
log.Debug( err.Error() )
c.Status( http.StatusInternalServerError )
return err
}
......@@ -96,7 +149,171 @@ func SetRoutes( router *f.App, config *configuration.Config, healthiness *bool )
})
statePathGroup := router.Group( "/state" )
statePathGroup.Options( "/:name", func( c *f.Ctx ) error {
name := strings.Clone( c.Params( "name" ) )
existingItem, err := store.Fetch( name )
if err != nil {
log.Debug( err.Error() )
return c.SendStatus( http.StatusInternalServerError )
}
if existingItem == nil {
return c.SendStatus( http.StatusNotFound )
}
c.Set( "Allow", "OPTIONS, GET, PUT, DELETE, HEAD" )
return c.SendStatus( http.StatusNoContent )
})
statePathGroup.Get( "/:name", func( c *f.Ctx ) error {
existingItem, err := store.Fetch( c.Params( "name" ) )
if err != nil {
log.Debug( err.Error() )
c.Status( http.StatusInternalServerError )
return c.Send( nil )
}
if existingItem == nil {
return c.SendStatus( http.StatusNotFound )
}
c.Set( "Content-Type", existingItem.MimeType() )
return c.Send( existingItem.Data() )
})
statePathGroup.Put( "/:name", func( c *f.Ctx ) error {
contentType := strings.Clone( c.Get( "Content-Type" ) )
_, _, err := mime.ParseMediaType( contentType )
if err != nil {
c.Status( http.StatusBadRequest )
return c.SendString(
fmt.Sprintf( "Invalid MIME type: %s", contentType ),
)
}
name := strings.Clone( c.Params( "name" ) )
existingItem, err := store.Fetch( name )
if err != nil {
log.Debug( err.Error() )
c.Status( http.StatusInternalServerError )
return c.Send( nil )
}
if existingItem != nil {
if bytes.Equal( existingItem.Data(), c.Body() ) &&
existingItem.MimeType() == contentType {
c.Set( "Content-Type", "text/plain; charset=utf-8" )
c.Status( http.StatusOK )
return c.SendString( "Resource not changed" )
}
c.Status( http.StatusNoContent )
} else {
c.Status( http.StatusCreated )
}
newItem := state.NewItem(
name,
contentType,
c.Body(),
)
if err = store.Add( newItem ); err != nil {
log.Debug( err.Error() )
c.Status( http.StatusInternalServerError )
return c.Send( nil )
}
c.Set( "Content-Location", c.Path() )
return c.Send( nil )
})
statePathGroup.Delete( "/:name", func( c *f.Ctx ) error {
name := strings.Clone( c.Params( "name" ) )
existingItem, err := store.Fetch( name )
if err != nil {
log.Debug( err.Error() )
return c.SendStatus( http.StatusInternalServerError )
}
if existingItem == nil {
return c.SendStatus( http.StatusNotFound )
}
if err = store.Remove( name ); err != nil {
log.Debug( err.Error() )
return c.SendStatus( http.StatusInternalServerError )
}
return c.SendStatus( http.StatusNoContent )
})
statePathGroup.Head( "/:name", func( c *f.Ctx ) error {
name := strings.Clone( c.Params( "name" ) )
existingItem, err := store.Fetch( name )
if err != nil {
log.Debug( err.Error() )
return c.SendStatus( http.StatusInternalServerError )
}
if existingItem == nil {
return c.SendStatus( http.StatusNotFound )
}
c.Set( "Content-Type", existingItem.MimeType() )
c.Set( "Content-Length", fmt.Sprintf( "%d", len( existingItem.Data() ) ) )
return c.SendStatus( http.StatusOK )
})
statePathGroup.Use( "*", func( c *f.Ctx ) error {
return c.SendStatus( http.StatusNotFound )
})
router.Get( "/states", func( c *f.Ctx ) error {
names, err := store.List()
if err != nil {
log.Debug( err.Error() )
return c.SendStatus( http.StatusInternalServerError )
}
const pathPrefix string = "/state"
paths := make( []string, len( names ) )
for i, name := range names {
paths[ i ] = fmt.Sprintf( "%s/%s", pathPrefix, name )
}
headers := c.GetReqHeaders()
acceptHeader := strings.Join( headers[ "Accept" ], " " )
var response string
if strings.Contains( acceptHeader, "json" ) {
c.Set( "Content-Type", "application/json; charset=utf-8" )
resJson, err := json.Marshal( paths )
if err != nil {
return err
}
response = string( resJson )
} else {
c.Set( "Content-Type", "text/plain; charset=utf-8" )
response = strings.Join( paths, "\n" )
}
c.Status( http.StatusOK )
return c.SendString( response )
})
router.Use( func( c *f.Ctx ) error {
return c.SendStatus( http.StatusTeapot )
})
return nil
}
package routing
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"math/rand"
"encoding/json"
......@@ -15,22 +18,24 @@ import (
"github.com/stretchr/testify/assert"
"webservice/configuration"
"webservice/state"
)
func setup() ( *f.App, *configuration.Config, *bool ){
func setup() ( *f.App, *configuration.Config, state.Store, *bool ){
os.Setenv( "ENV_NAME", "testing" )
config, _ := configuration.New()
server := f.New( f.Config{
AppName: "test",
DisableStartupMessage: false,
BodyLimit: configuration.BODY_SIZE_LIMIT,
})
store := state.NewEphemeralStore()
var isHealthy = true
SetRoutes( server, config, &isHealthy )
_ = SetRoutes( server, config, store, &isHealthy )
return server, config, &isHealthy
return server, config, store, &isHealthy
}
......@@ -59,6 +64,20 @@ func jsonToMap( body *io.ReadCloser ) ( map[string]interface{}, error ) {
return data, nil
}
func jsonToStringSlice( body *io.ReadCloser ) ( []string, error ) {
defer ( *body ).Close()
var data []string
bodyBytes, err := io.ReadAll( *body )
if err != nil {
return data, err
}
if err := json.Unmarshal( bodyBytes, &data ); err != nil {
return data, err
}
return data, nil
}
func generateRandomNumberString() string {
r := rand.New( rand.NewSource( time.Now().Unix() ) )
......@@ -66,9 +85,16 @@ func generateRandomNumberString() string {
return fmt.Sprintf( "%d", randomNumber )
}
func generateRandomBytes( length int ) []byte {
r := rand.New( rand.NewSource( time.Now().Unix() ) )
bytes := make( []byte, length )
_, _ = r.Read( bytes )
return bytes
}
func TestIndexRoute( t *testing.T ){
router, _, _ := setup()
router, _, _, _ := setup()
req := ht.NewRequest( "GET", "/", nil )
req.Header.Add( "Accept", "text/html" )
......@@ -87,10 +113,10 @@ func TestIndexRoute( t *testing.T ){
func TestHealthRoute( t *testing.T ){
router, _, healthiness := setup()
router, _, _, healthiness := setup()
req := ht.NewRequest( "GET", "/health", nil )
res, err := router.Test( req, -1 )
res, _ := router.Test( req, -1 )
bodyContent, err := jsonToMap( &res.Body )
status := bodyContent[ "status" ].( string )
assert.Equal( t, http.StatusOK, res.StatusCode )
......@@ -100,7 +126,7 @@ func TestHealthRoute( t *testing.T ){
*healthiness = false
req = ht.NewRequest( "GET", "/health", nil )
res, err = router.Test( req, -1 )
res, _ = router.Test( req, -1 )
bodyContent, err = jsonToMap( &res.Body )
status = bodyContent[ "status" ].( string )
assert.Equal( t, http.StatusServiceUnavailable, res.StatusCode )
......@@ -110,7 +136,7 @@ func TestHealthRoute( t *testing.T ){
func TestEnvRoute( t *testing.T ){
router, config, _ := setup()
router, config, _, _ := setup()
envVarName := "TEST_ENV_VAR"
envVarValue := generateRandomNumberString()
......@@ -118,7 +144,7 @@ func TestEnvRoute( t *testing.T ){
os.Setenv( envVarName, envVarValue )
req := ht.NewRequest( "GET", "/env", nil )
res, err := router.Test( req, -1 )
res, _ := router.Test( req, -1 )
bodyContent, err := bodyToString( &res.Body )
assert.Equal( t, http.StatusOK, res.StatusCode )
assert.Nil( t, err )
......@@ -127,6 +153,104 @@ func TestEnvRoute( t *testing.T ){
( *config ).Environment = "production"
req = ht.NewRequest( "GET", "/env", nil )
res, err = router.Test( req, -1 )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusForbidden, res.StatusCode )
}
func TestState( t *testing.T ){
router, _, store, _ := setup()
const statePath1 = "/state/just-a-test"
const statePath1Mime = "text/plain"
const statePath1Body1 = "just a test body"
const statePath1Body2 = "this body just changed"
const statePath2 = "/state/another-test"
const statePath2Mime = "application/octet-stream"
const statePath2BodySize = 128
statePath2Body := generateRandomBytes( statePath2BodySize )
req := ht.NewRequest( "GET", statePath1, nil )
res, _ := router.Test( req, -1 )
assert.Equal( t, http.StatusNotFound, res.StatusCode )
req = ht.NewRequest( "PUT", statePath1, nil )
req.Header.Add( "Content-Type", "not a MIME type" )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusBadRequest, res.StatusCode )
req = ht.NewRequest( "PUT", statePath1, strings.NewReader( statePath1Body1 ) )
req.Header.Add( "Content-Type", statePath1Mime )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusCreated, res.StatusCode )
req = ht.NewRequest( "PUT", statePath1, strings.NewReader( statePath1Body1 ) )
req.Header.Add( "Content-Type", statePath1Mime )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusOK, res.StatusCode )
req = ht.NewRequest( "PUT", statePath1, strings.NewReader( statePath1Body2 ) )
req.Header.Add( "Content-Type", statePath1Mime )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusNoContent, res.StatusCode )
req = ht.NewRequest( "GET", statePath1, nil )
res, _ = router.Test( req, -1 )
bodyContent, err := bodyToString( &res.Body )
assert.Nil( t, err )
assert.Equal( t, http.StatusOK, res.StatusCode )
assert.Equal( t, statePath1Mime, res.Header[ "Content-Type" ][0] )
assert.Equal( t, statePath1Body2, bodyContent )
req = ht.NewRequest( "DELETE", statePath2, nil )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusNotFound, res.StatusCode )
req = ht.NewRequest( "PUT", statePath2, bytes.NewReader( statePath2Body ) )
req.Header.Add( "Content-Type", statePath2Mime )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusCreated, res.StatusCode )
req = ht.NewRequest( "HEAD", statePath2, nil )
res, _ = router.Test( req, -1 )
contentLength, err := strconv.ParseInt( res.Header[ "Content-Length" ][0], 10, 64 )
assert.Nil( t, err )
assert.Equal( t, http.StatusOK, res.StatusCode )
assert.Equal( t, statePath2Mime, res.Header[ "Content-Type" ][0] )
assert.Equal( t, int64( statePath2BodySize ), contentLength )
assert.IsType( t, res.Body, http.NoBody )
req = ht.NewRequest( "GET", statePath2, nil )
res, _ = router.Test( req, -1 )
bodyBytes, err := io.ReadAll( res.Body )
assert.Equal( t, http.StatusOK, res.StatusCode )
assert.Equal( t, statePath2Mime, res.Header[ "Content-Type" ][0] )
assert.Equal( t, statePath2Body, bodyBytes )
req = ht.NewRequest( "GET", "/states", nil )
req.Header.Add( "Accept", "application/json" )
res, _ = router.Test( req, -1 )
states, err := jsonToStringSlice( &res.Body )
assert.Nil( t, err )
assert.Len( t, states, 2 )
assert.Contains( t, states, statePath1 )
assert.Contains( t, states, statePath2 )
req = ht.NewRequest( "DELETE", statePath1, nil )
res, _ = router.Test( req, -1 )
assert.Equal( t, http.StatusNoContent, res.StatusCode )
req = ht.NewRequest( "GET", "/states", nil )
res, _ = router.Test( req, -1 )
statesPlain, err := bodyToString( &res.Body )
assert.Nil( t, err )
assert.NotContains( t, statesPlain, statePath1 )
assert.Contains( t, statesPlain, statePath2 )
err = store.Disconnect()
req = ht.NewRequest( "GET", statePath2, nil )
res, _ = router.Test( req, -1 )
assert.Nil( t, err )
assert.Equal( t, http.StatusInternalServerError, res.StatusCode )
}
......@@ -20,3 +20,14 @@ type indexHtmlData struct {
Version string
Color string
}
const metricsText = `
# HELP state_entries_quantity The current number of state entries being stored
# TYPE state_entries_quantity gauge
state_entries_quantity {{ .Count }}
`
type metricsTextData struct {
Count int
}
package state
import (
"errors"
"sync"
)
type Ephemeral struct {
store map[ string ] Item
mux sync.Mutex
}
func NewEphemeralStore() *Ephemeral {
return &Ephemeral{
store: map[ string ] Item {},
mux: sync.Mutex{},
}
}
func ( e *Ephemeral ) Add( i Item ) error {
if e.store == nil {
return errors.New( "ephemeral storage not available" )
}
name := i.Name()
e.mux.Lock()
e.store[ name ] = i
e.mux.Unlock()
return nil
}
func ( e *Ephemeral ) Remove( name string ) error {
if e.store == nil {
return errors.New( "ephemeral storage not available" )
}
e.mux.Lock()
delete( e.store, name )
e.mux.Unlock()
return nil
}
func ( e *Ephemeral ) Fetch( name string ) ( *Item, error ) {
if e.store == nil {
return nil, errors.New( "ephemeral storage not available" )
}
e.mux.Lock()
item, found := e.store[ name ]
e.mux.Unlock()
if !found {
return nil, nil
}
return &item, nil
}
func ( e *Ephemeral ) List() ( []string, error ) {
if e.store == nil {
return nil, errors.New( "ephemeral storage not available" )
}
e.mux.Lock()
names := make( []string, 0, len( e.store ) )
for _, item := range e.store {
names = append( names, item.Name() )
}
e.mux.Unlock()
return names, nil
}
func ( e *Ephemeral ) Disconnect() error {
e.store = nil
return nil
}
package state
import (
"testing"
"mime"
"sync"
"github.com/stretchr/testify/assert"
)
var testItems = [] Item {
NewItem( "foo", "bar", []byte( "fasel" ) ),
NewItem( "qwertyASDFGH", mime.TypeByExtension( ".html" ), []byte{} ),
Item{
name: "Som!_🎵nam3",
mimeType: "any kind of string",
data: []byte{ 1, 2, 3, 4, 5, 6, 7, 8 },
},
}
func TestEphemeralAdd( t *testing.T ){
es := NewEphemeralStore()
wg := &sync.WaitGroup{}
for _, item := range testItems {
wg.Add( 1 )
go func( i Item ){
defer wg.Done()
es.Add( i )
}( item )
}
wg.Wait()
assert.Len( t, es.store, len( testItems ) )
}
package state
type Item struct {
name string
mimeType string
data []byte
}
func NewItem( name string, mimeType string, data []byte ) Item {
return Item{
name: name,
mimeType: mimeType,
data: data,
}
}
func ( i *Item ) Name() string {
return i.name
}
func ( i *Item ) MimeType() string {
return i.mimeType
}
func ( i *Item ) Data() []byte {
return i.data
}
package state
import (
"fmt"
"runtime"
"context"
"time"
"os"
log "log/slog"
"webservice/configuration"
db "github.com/redis/go-redis/v9"
)
type Persistent struct {
client *db.Client
ctx context.Context
timeout time.Duration
}
func NewPersistentStore( c *configuration.Config ) *Persistent {
content, err := os.ReadFile( c.DatabasePassword )
if err != nil {
log.Error( fmt.Sprintf( "Database password not able to be read: %v", err ) )
os.Exit( 1 )
}
dbPassword := string( content )
return &Persistent{
client: db.NewClient( &db.Options{
Addr: fmt.Sprintf( "%s:%d", c.DatabaseHost, c.DatabasePort ),
Username: c.DatabaseUsername,
Password: dbPassword,
DB: c.DatabaseName,
DialTimeout: time.Second * 3,
ContextTimeoutEnabled: true,
MaxRetries: 3,
MinRetryBackoff: time.Second * 1,
MaxRetryBackoff: time.Second * 2,
PoolSize: 10 * runtime.NumCPU(),
MaxActiveConns: 10 * runtime.NumCPU(),
}),
timeout: time.Second * 20,
}
}
func ( e *Persistent ) Add( i Item ) error {
ctx, cancel := context.WithTimeout( context.TODO(), e.timeout )
defer cancel()
name := i.Name()
if err := e.client.HSet(
ctx, name,
"mime", i.MimeType(),
"data", i.Data(),
).Err(); err != nil {
return err
}
return nil
}
func ( e *Persistent ) Remove( name string ) error {
ctx, cancel := context.WithTimeout( context.TODO(), e.timeout )
defer cancel()
if err := e.client.Del( ctx, name ).Err(); err != nil {
return err
}
return nil
}
func ( e *Persistent ) Fetch( name string ) ( *Item, error ) {
ctx, cancel := context.WithTimeout( context.TODO(), e.timeout )
defer cancel()
value, err := e.client.HGetAll( ctx, name ).Result()
if err != nil {
return nil, err
}
var item *Item = nil
if len( value ) >= 1 {
i := NewItem( name, value[ "mime" ], []byte( value[ "data" ] ) )
item = &i
}
return item, nil
}
func ( e *Persistent ) List() ( []string, error ) {
ctx, cancel := context.WithTimeout( context.TODO(), e.timeout )
defer cancel()
var names []string
i := e.client.Scan( ctx, 0, "", 0 ).Iterator()
for i.Next( ctx ){
names = append( names, i.Val() )
}
if err := i.Err(); err != nil {
return nil, err
}
return names, nil
}
func ( e *Persistent ) Disconnect() error {
return e.client.Close()
}
\ No newline at end of file
package state
type Store interface {
Add( i Item ) error
Remove( name string ) error
Fetch( name string ) ( *Item, error )
List() ( []string, error )
Disconnect() error
}