diff --git a/README.md b/README.md index adb301f230a9136e279c40f2f94c15f71e7b28d5..986872e20fc9333bd0c8dd894d6d1abc9fe5ba61 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 7eb62c5ab4956c6adddb9531fa802ee46581141b..c7866eb241bba73c665589eff4684c64edb481b6 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 9f9ac3c6db9dcc1ff9cc5dd0aa320d544d74a3b2..048249a24e88c004098deef41d02695836f7c52d 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 15b1e93c3758ea10d634e42638924c8f18951392..c7295c855e9e4509280a1f44c2792aed68574807 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 0000000000000000000000000000000000000000..e01ac90f88824fbbc1595ead4a31c9ec394f03e3 --- /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 0000000000000000000000000000000000000000..ccfea74f834450aaf5b10d6504ff94d9e52c25e1 --- /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 0000000000000000000000000000000000000000..c8ec606aacf99790fedf1b2efc038f68190cbcb4 --- /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 +}