Refactor router and storage
* Split and extract data-logic from application level * Move storage to DeeSee package * Move router into internal package * Fix Makefile * Fix documentation * Fix tests
This commit is contained in:
176
internal/router/router.go
Normal file
176
internal/router/router.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/eslider/superherohub/pkg/deesee"
|
||||
"github.com/eslider/superherohub/pkg/deesee/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Router is a custom router that encapsulates the mux.Router
|
||||
type Router struct {
|
||||
store *storage.DeeSee // The store where superheroes are stored
|
||||
router *mux.Router // Encapsulated mux router
|
||||
logger *log.Logger // The logger to use for this router
|
||||
key int // The key to use for DeeSee encryption
|
||||
}
|
||||
|
||||
// New creates a new encapsulated router.
|
||||
//
|
||||
// The pattern I use is to define a custom router structure
|
||||
// that has a `mux.Router` as the field and also encapsulates things
|
||||
// like the JSON store to load, encryption-keys, database connection,
|
||||
// application configuration, and so on.
|
||||
//
|
||||
// This makes it easy to update routes as they
|
||||
// require different resources and development progresses.
|
||||
//
|
||||
// And handlers shouldn't have prefix like "handle", course they are methods of the Router
|
||||
func New(heros *storage.DeeSee, key int, logger *log.Logger) *Router {
|
||||
// Set the logger
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
|
||||
r := &Router{
|
||||
store: heros,
|
||||
key: key,
|
||||
logger: logger,
|
||||
router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
r.router.Use(r.loggingMiddleware)
|
||||
|
||||
// Set a custom 404 handler
|
||||
r.router.NotFoundHandler = http.HandlerFunc(r.HandleNotFound)
|
||||
|
||||
// Set `getSuperHeroes` as the handler for the route
|
||||
r.router.HandleFunc("/superheroes", r.GetSuperHeroes).Methods(http.MethodGet)
|
||||
|
||||
// Set `putSuperHero` as the handler for the route
|
||||
r.router.HandleFunc("/superheroes", r.StoreSuperHero).Methods(http.MethodPut)
|
||||
|
||||
// Enable CORS by uncommenting the following line
|
||||
// router.Use(mux.CORSMethodMiddleware(router))
|
||||
|
||||
// Maybe better to use /api prefix for all routes?
|
||||
// subrouter := router.PathPrefix("/api").Subrouter()
|
||||
return r
|
||||
}
|
||||
|
||||
// GetHandler returns the encapsulated router
|
||||
func (r *Router) GetHandler() http.Handler {
|
||||
return r.router
|
||||
}
|
||||
|
||||
// GetSuperHeroes returns a list of superheroes
|
||||
// with optional filtering and encryption of identities.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// - Retrieve all superheroes
|
||||
//
|
||||
// GET /superheroes
|
||||
//
|
||||
// - Retrieve all superheroes with encrypted identities:
|
||||
//
|
||||
// GET /superheroes?encrypted=true
|
||||
//
|
||||
// - Retrieve superheroes that match given superpower(s)
|
||||
//
|
||||
// GET /superheroes?superpowers=flight,super-strength
|
||||
//
|
||||
// - Retrieve superheroes that match given superpower(s) with encrypted identities
|
||||
//
|
||||
// GET /superheroes?superpowers=flight,super-strength&encrypted=true
|
||||
func (r *Router) GetSuperHeroes(w http.ResponseWriter, req *http.Request) {
|
||||
var (
|
||||
// Resulting
|
||||
heroes = *r.store
|
||||
// Get query parameter values
|
||||
params = req.URL.Query()
|
||||
err error
|
||||
)
|
||||
|
||||
// Filter superheroes by superpowers?
|
||||
// The name of the power should be long enough to search for, at least 3 chars.
|
||||
if powerFilter := params.Get("superpowers"); len(powerFilter) > 2 {
|
||||
|
||||
// Here we use the `strings` package to split comma-separated power names.
|
||||
// Maybe it's better to prevent send special chars by remove with regular expression.
|
||||
powers := strings.Split(powerFilter, ",")
|
||||
heroes = deesee.SearchByPowers(heroes, powers)
|
||||
}
|
||||
|
||||
// Encrypt identities with DeeSee encryption algorithm?
|
||||
if strings.EqualFold(params.Get("encrypted"), "true") {
|
||||
// Encrypt identities
|
||||
heroes = deesee.EncryptHerosIdentities(heroes, r.key)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return JSON
|
||||
if err = json.NewEncoder(w).Encode(heroes); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// StoreSuperHero saves one superhero
|
||||
func (r *Router) StoreSuperHero(w http.ResponseWriter, req *http.Request) {
|
||||
var (
|
||||
err error
|
||||
hero = &deesee.Superhero{}
|
||||
)
|
||||
|
||||
// Decode JSON
|
||||
if err = json.NewDecoder(req.Body).Decode(hero); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Store superhero
|
||||
if err = r.store.Store(hero); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Return JSON
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
// Response body
|
||||
_, err = w.Write([]byte(`{
|
||||
"message": "Superhero stored successfully.",
|
||||
"status": "success"
|
||||
}`))
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNotFound is a custom 404 handler
|
||||
func (r *Router) HandleNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, err := w.Write([]byte(`{"error": "not found"}`))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// loggingMiddleware is a middleware that logs all requests
|
||||
func (r *Router) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
r.logger.Printf("Request: %s %s", req.Method, req.RequestURI)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
172
internal/router/router_test.go
Normal file
172
internal/router/router_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/eslider/superherohub/pkg/deesee"
|
||||
"github.com/eslider/superherohub/pkg/deesee/storage"
|
||||
"github.com/eslider/superherohub/pkg/file"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWebserviceGetSuperHeros tests the HandleWebserviceRequest function
|
||||
func TestWebserviceGetSuperHeros(t *testing.T) {
|
||||
|
||||
path, err := file.GetModRootPath()
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting module root path: %s", err)
|
||||
}
|
||||
|
||||
// Load heros
|
||||
heros, err := storage.New(path + "/data/heros.json")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading heros: %heros", err)
|
||||
}
|
||||
|
||||
// Create router
|
||||
router := New(heros, 5, nil)
|
||||
|
||||
// Test cases
|
||||
for _, tc := range []struct {
|
||||
name string // Test name
|
||||
url string // URL to test
|
||||
encrypted bool // Encrypted identities
|
||||
filterBy *[]string // Superpowers to filter by
|
||||
}{
|
||||
{"Test retrieve all superheroes", "/superheroes", false, nil},
|
||||
{"Test retrieve all encrypted identities", "/superheroes?encrypted=true", true, nil},
|
||||
{"Test retrieve superheroes that match given superpower(s)", "/superheroes?superpowers=healing", false, &[]string{"healing"}},
|
||||
{"Test retrieve superheroes that match given superpower(s) with encrypted identities", "/superheroes?superpowers=healing&encrypted=true", true, &[]string{"healing"}},
|
||||
} {
|
||||
// Run test case
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest(http.MethodGet, tc.url, strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatal("Error creating request: ", err)
|
||||
}
|
||||
|
||||
// Create response recorder
|
||||
rec := httptest.NewRecorder()
|
||||
router.GetSuperHeroes(rec, req)
|
||||
res := rec.Result()
|
||||
|
||||
// Test status code
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status code %d, got %d", http.StatusOK, res.StatusCode)
|
||||
}
|
||||
|
||||
// Test content type
|
||||
if res.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected content type %s, got %s", "application/json", res.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
// Test decode response
|
||||
heros := &[]*deesee.Superhero{}
|
||||
if err = json.NewDecoder(res.Body).Decode(heros); err != nil {
|
||||
t.Fatalf("Error decoding response: %heros", err)
|
||||
}
|
||||
|
||||
// Test non-nil response
|
||||
if heros == nil {
|
||||
t.Fatalf("Expected non-nil response")
|
||||
}
|
||||
|
||||
// Test length of response
|
||||
if len(*heros) < 1 {
|
||||
t.Fatalf("Expected non-empty response")
|
||||
}
|
||||
|
||||
// Test heros entries
|
||||
for _, hero := range *heros {
|
||||
|
||||
// Test heros identities
|
||||
if hero.Identity == nil {
|
||||
t.Errorf("Expected identity to be %s, got %s", "unknown", hero.Identity)
|
||||
}
|
||||
|
||||
// Test encrypted identities
|
||||
if tc.encrypted {
|
||||
if hero.Name == "superman" && (hero.Identity.FirstName != "hqfwp" || hero.Identity.LastName != "pjsy") {
|
||||
t.Errorf("Expected encoded superman identity")
|
||||
}
|
||||
}
|
||||
|
||||
// Test superpowers
|
||||
if hero.SuperPowers != nil {
|
||||
for _, gotPower := range *hero.SuperPowers {
|
||||
if !deesee.IsAcceptable(gotPower) {
|
||||
t.Errorf("Expected superpower %s to be acceptable", gotPower)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test superpowers filter
|
||||
if tc.filterBy != nil {
|
||||
for _, wantPower := range *tc.filterBy {
|
||||
if !hero.Has(wantPower) {
|
||||
t.Errorf("Expected superpower %s to be in %s", wantPower, hero.SuperPowers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebserviceStoreSuperhero tests the HandleWebserviceRequest function
|
||||
// TODO: Test invalid storing data (e.g. invalid JSON)
|
||||
func TestWebserviceStoreSuperhero(t *testing.T) {
|
||||
// Load heros
|
||||
path, err := file.GetModRootPath()
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting module root path: %s", err)
|
||||
}
|
||||
|
||||
// Load heros
|
||||
heros, err := storage.New(path + "/data/heros.json")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading heros: %heros", err)
|
||||
}
|
||||
|
||||
// Create router
|
||||
router := New(heros, 5, nil)
|
||||
|
||||
// Test with valid domain model
|
||||
req, err := http.NewRequest(http.MethodGet, "/superheroes", strings.NewReader(`{
|
||||
"name": "ironman",
|
||||
"identity": {
|
||||
"firstName": "tony",
|
||||
"lastName": "stark"
|
||||
},
|
||||
"birthday": "1970-05-29",
|
||||
"superpowers": [
|
||||
"intelligence",
|
||||
"flight"
|
||||
]
|
||||
}`))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal("Error creating request: ", err)
|
||||
}
|
||||
|
||||
// Create response recorder
|
||||
rec := httptest.NewRecorder()
|
||||
router.StoreSuperHero(rec, req)
|
||||
res := rec.Result()
|
||||
|
||||
// Test status code
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
t.Errorf("Expected status code %d, got %d", http.StatusCreated, res.StatusCode)
|
||||
}
|
||||
|
||||
// Test content type
|
||||
if res.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected content type %s, got %s", "application/json", res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user