From 0dc754e2e19453fe673b67f5ee3b8dbf72fcc527 Mon Sep 17 00:00:00 2001 From: gjahn <gregor.jahn@bht-berlin.de> Date: Sat, 25 Nov 2023 00:10:01 +0100 Subject: [PATCH] Add state For now it's just ephemeral. Instead of disabling the timeout (-1), it's set to 1 ms for all requests in the state paths test. Otherwise race condistion may occur. Symptoms include incorrect state name, because it may have been read before it's fully written to the store. --- README.md | 96 +++++++++++++++++++++++++++++++ main.go | 9 ++- routing/routes.go | 128 ++++++++++++++++++++++++++++++++++++++++- routing/routes_test.go | 127 +++++++++++++++++++++++++++++++++++++--- state/ephemeral.go | 70 ++++++++++++++++++++++ state/item.go | 30 ++++++++++ state/store.go | 12 ++++ 7 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 state/ephemeral.go create mode 100644 state/item.go create mode 100644 state/store.go diff --git a/README.md b/README.md index adb301f..986872e 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,99 @@ or even used the project work. To build for another platform, set `GOOS` and `GOARCH`. To yield a static binary (fully self-contained, no dynamic linking) set `CGO_ENABLED=0`. For more details, 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 'Content-Type: 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 +``` + +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 +``` diff --git a/main.go b/main.go index 7eb62c5..c7866eb 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "webservice/configuration" "webservice/routing" + "webservice/state" "github.com/gofiber/fiber/v2" ) @@ -27,9 +28,11 @@ func main() { DisableStartupMessage: config.Environment != "development", }) + store := state.NewEphemeralStore() + var isHealthy = false - routing.SetRoutes( server, config, &isHealthy ) + routing.SetRoutes( server, config, store, &isHealthy ) go func(){ err := server.Listen( fmt.Sprintf( "%s:%d", config.Host, config.Port ) ) @@ -65,6 +68,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(): diff --git a/routing/routes.go b/routing/routes.go index 9f9ac3c..048249a 100644 --- a/routing/routes.go +++ b/routing/routes.go @@ -9,14 +9,16 @@ import ( "html/template" "log" "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 ) { indexHtmlTemplate, err := template.New( "index" ).Parse( indexHtml ) if err != nil { @@ -96,6 +98,130 @@ func SetRoutes( router *f.App, config *configuration.Config, healthiness *bool ) }) + statePathGroup := router.Group( "/state" ) + + + statePathGroup.Get( "/:name", func( c *f.Ctx ) error { + existingItem, err := store.Fetch( c.Params( "name" ) ) + if err != nil { + 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 := 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 := c.Params( "name" ) + existingItem, err := store.Fetch( name ) + if err != nil { + 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 { + 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 := c.Params( "name" ) + existingItem, err := store.Fetch( name ) + if err != nil { + return c.SendStatus( http.StatusInternalServerError ) + } + + if existingItem == nil { + return c.SendStatus( http.StatusNotFound ) + } + + if err = store.Remove( name ); err != nil { + return c.SendStatus( http.StatusInternalServerError ) + } + + return c.SendStatus( http.StatusNoContent ) + }) + + + statePathGroup.Use( "*", func( c *f.Ctx ) error { + if method := c.Method(); method == "OPTIONS" { + c.Set( "Allow", "GET, PUT, DELETE, OPTIONS" ) + return c.SendStatus( http.StatusNoContent ) + } + + return c.SendStatus( http.StatusNotFound ) + }) + + + router.Get( "/states", func( c *f.Ctx ) error { + states, err := store.Show() + if err != nil { + return c.SendStatus( http.StatusInternalServerError ) + } + + const pathPrefix string = "/state" + paths := make ( []string, len( states ) ) + for i, state := range states { + paths[ i ] = fmt.Sprintf( "%s/%s", pathPrefix, state ) + } + + headers := c.GetReqHeaders() + var response string + if strings.Contains( headers[ "Accept" ], "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 ) }) diff --git a/routing/routes_test.go b/routing/routes_test.go index 15b1e93..c7295c8 100644 --- a/routing/routes_test.go +++ b/routing/routes_test.go @@ -1,9 +1,11 @@ package routing import ( + "bytes" "fmt" "io" "os" + "strings" "time" "math/rand" "encoding/json" @@ -15,10 +17,11 @@ 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() @@ -26,11 +29,11 @@ func setup() ( *f.App, *configuration.Config, *bool ){ AppName: "test", DisableStartupMessage: false, }) - + 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 +62,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 +83,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,7 +111,7 @@ 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 ) @@ -110,7 +134,7 @@ func TestHealthRoute( t *testing.T ){ func TestEnvRoute( t *testing.T ){ - router, config, _ := setup() + router, config, _, _ := setup() envVarName := "TEST_ENV_VAR" envVarValue := generateRandomNumberString() @@ -130,3 +154,92 @@ func TestEnvRoute( t *testing.T ){ res, err = 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 = 64 + 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( "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 ) +} diff --git a/state/ephemeral.go b/state/ephemeral.go new file mode 100644 index 0000000..e01ac90 --- /dev/null +++ b/state/ephemeral.go @@ -0,0 +1,70 @@ +package state + +import ( + "errors" +) + + +type Ephemeral struct { + store map[ string ] *Item +} + + +func NewEphemeralStore() *Ephemeral { + return &Ephemeral{ + store: map[ string ] *Item {}, + } +} + + +func ( e *Ephemeral ) Add( i *Item ) error { + if e.store == nil { + return errors.New( "ephemeral storage not available" ) + } + + name := i.Name() + e.store[ name ] = i + return nil +} + + +func ( e *Ephemeral ) Remove( name string ) error { + if e.store == nil { + return errors.New( "ephemeral storage not available" ) + } + + delete( e.store, name ) + return nil +} + + +func ( e *Ephemeral ) Fetch( name string ) ( *Item, error ) { + if e.store == nil { + return nil, errors.New( "ephemeral storage not available" ) + } + + item, found := e.store[ name ] + if !found { + return nil, nil + } + return item, nil +} + + +func ( e *Ephemeral ) Show() ( []string, error ) { + if e.store == nil { + return nil, errors.New( "ephemeral storage not available" ) + } + + names := make( []string, 0, len( e.store ) ) + for k := range e.store { + names = append( names, k ) + } + return names, nil +} + + +func ( e *Ephemeral ) Disconnect() error { + e.store = nil + return nil +} diff --git a/state/item.go b/state/item.go new file mode 100644 index 0000000..ccfea74 --- /dev/null +++ b/state/item.go @@ -0,0 +1,30 @@ +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 +} diff --git a/state/store.go b/state/store.go new file mode 100644 index 0000000..c8ec606 --- /dev/null +++ b/state/store.go @@ -0,0 +1,12 @@ +package state + + + +type Store interface { + Add( i *Item ) error + Remove( name string ) error + Fetch( name string ) ( *Item, error ) + Show() ( []string, error ) + + Disconnect() error +} -- GitLab