commit af7291815db918302c8db018e1ae76fe98a3ddf7 Author: Andriy Oblivantsev Date: Tue Feb 7 04:44:07 2023 +0000 Add initial project files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73fa446 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# IDE files +/.idea +/*.iml + +# PDF's +data/*.pdf + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..0a8642f --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Zeppelin ignored files +/ZeppelinRemoteNotebooks/ diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..4e28a22 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..76c9ca4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..57bbd51 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..f5f2744 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CHALLENGE.md b/CHALLENGE.md new file mode 100644 index 0000000..a1c1c9d --- /dev/null +++ b/CHALLENGE.md @@ -0,0 +1,138 @@ +# Introduction: AOE Backend Challenge + +This challenge is intended to get an idea of the knowledge and experience you have as a +developer. We would like to give you the opportunity to showcase your knowledge and skills +based on different requirements. Please read through this document carefully before starting +and have fun solving our backend challenge! + +> Please implement the exercise using one of: java, kotlin, scala, go, php + +## Challenge Overview + +Our coding challenge is divided into 3 tasks which will be explained in more detail in chapter 2. To +give you an first impression, here are the tasks that will be required to be implemented: + +* String encryption +* Domain modelling (with filter logic) +* Webservice combining the domain model, filter logic and encryption + +Depending on your knowledge, experience and your available time you could consider skipping +the third challenge, i.e. if you apply as a working student and haven’t touched any webservice yet. +Like with everything: Don’t stress yourself too much. If you notice, that you spend too much time +or can’t get a grip around a task, there’s no shame in skipping an exercise. Just let us know in the +[readme](README.md) why you might have decided not to implement it. + +## Evaluation Criteria + +As this challenge is used to assess your experience and skill level, we want to be transparent +about our evaluation and give you some hints about what is important to us. The following points +in no particular order are some of the criteria we will analyse: + +* Understandable code (naming, structure, readability) +* Project structure (package structure, reusability) +* Meaningful unit test coverage + +## Submission + +Please provide your implementation either as an archive via mail or preferably via github (private +repository, shared with: [code-review-backend@aoe.com](mailto:code-review-backend@aoe.com)). +Ensure to include the following things in your submission: + +* Source code +* Readme + * Short explanation of how to run your code + * Optional: Notes on specific tasks if you want to clarify or explain something + +Please provide all of your text documents as either txt, md or pdf files. + +# Coding Challenge + +Since the number of superheroes in our world is growing bigger and bigger, a company called +“DeeSee Comics” has instructed us to build a superhero distribution platform to keep track of +their employed superheroes. The first feature of the new platform will be a webservice that +provides a way to store and retrieve superheroes. Superheroes can be filtered by their powers and +their true identity can be encrypted to further provide protection for the ones enrolled in the +platform. + +> **Note**: If you decide not to implement the webservice (2.3), please provide an executable code +> fragment, in which you showcase your implementation of 2.1 and 2.2. Therefor we recommend to +> just create a few superheroes, filter them by their superpowers and print the resulting superheroes +> with encrypted identities. + +## Identity Encryption + +Beginning with the most critical requirement: DeeSee requires the encryption of the true identity +of their superheroes. Therefore, it is essential to provide a function that takes an identity and +returns an encrypted identity. Contrary to our recommendation, DeeSee insists on using a +proprietary encryption called the “DeeSee Chiffre”. + +To encrypt a string using the “DeeSee Chiffre”, you will need to shift each letter of the identity by a +key n. For the sake of this exercise, you can assume that identities only contain lowercase +characters and spaces. The following example might help you understand the algorithm: + +| Input | Key | Output | +|----------------|-----|----------------| +| clark | 5 | hqfwp | +| cherry blossom | 3 | fkhuub eorvvrp | + +> **Note**: Please make sure to implement the algorithm by yourself without using available solutions +> from the internet / libraries. + +# Domain Model + +Before continuing to the webservice, we will need a domain model to efficiently work with our +stored superheroes. The following json contains an example of a superhero which can be used to +infer the domain model. + +```json +{ + "name": "superman", + "identity": { + "firstName": "clark", + "lastName": "kent" + }, + "birthday": "1977-04-18", + "superpowers": [ + "flight", + "strength", + "invulnerability" + ] +} +``` + +To keep the system as simple as possible, DeeSee has agreed to only accept superheroes with +the following superpowers: `strength`, `speed`, `flight`, `invulnerability`, `healing` +Backend Coding Challenge + +## Superhero + +For the core of the new distribution platform, we will need a webservice that will handle all the +superheroes via an RESTful API. The microservice needs to provide possibilities to allow DeeSee +to store and retrieve their superheroes. +It is up to you whether you implement the webservice by hand or use a framework of your choice. + +> Hint: Using a database for storing the data is not required nor encouraged. Keep it as simple as +> possible for this use-case. Data storage is not part of the evaluation. + +> **Hint**: Test data should be loaded on startup. Therefor we have provided you a json file containing +> some sample data you can use. How you use the json is up to you. Chose the easiest solution and +> avoid wasting time for this as it is not part of the evaluation either. + +### Superhero Retrieval + +To allow DeeSee to quickly find matching superheroes for their crime prevention system, the +webservice is required to provide an interface to quickly retrieve superheroes using HTTP. +Based on DeeSee’s requirements, the service must be able to provide all superheroes at once as +well as provide all matching superheroes given a required superpower. In addition, our customer +wants to have the possibility to tell the service to have all returned superheroes’ identity +encrypted (using 2.1). + +Instead of returning a superhero’s identity as the composed object described in 2.2, DeeSee has +requested to return the identity as a string in the form of “`$firstName $lastName`”. + +The following list shows some use-cases that should be supported: + +* Retrieve all superheroes +* Retrieve all superheroes with encrypted identities +* Retrieve superheroes that match given superpower(s) +* Retrieve superheroes that match given superpower(s) with encrypted identities diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b91d245 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +DIST_NAME=superhero +DIST_PATH=dist +PACKAGE_PATH=github.com/eslider/superherohub +BINARY_PATH=${DIST_PATH}/${DIST_NAME} + +# Build the project +build: + go build -o ${BINARY_PATH} ${PACKAGE_PATH} + + +# Run the project +run: + go build -o ${BINARY_PATH} ${PACKAGE_PATH} + ${BINARY_PATH} + + +# Test and build the project +test: + go test -v -cover ${PACKAGE_PATH}/pkg/deesee + go test -v -cover ${PACKAGE_PATH}/pkg/people + go test -v -cover -count 10 ${PACKAGE_PATH} + +# This test ork only by developer +# Please ignore this +test-store: + curl -v -X PUT \ + -H 'Accept: */*' \ + -H 'Connection: keep-alive' \ + -H 'Cache-Control: no-cache' \ + -H "Content-Type: application/json" \ + -d '{"name":"supermans","identity":{"firstName":"Kent","lastName":"Clark"},"superpowers":["flight","strength","invulnerability"],"birthday":"1977-04-18"}' \ + http://127.0.0.1:8080/superheroes + +# Clean the project +clean: + go clean + rm ${BINARY_PATH} diff --git a/README.md b/README.md new file mode 100644 index 0000000..30f2d90 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# **DeeSee Comics** Superhero Distribution Platform + +Distribution platform to keep track of employed superheroes as a web service. + +## Introduction + +This project is a solution to the AOE Backend Challenge. +It is a simple REST API that allows to only retrieve superheroes. + +## Features + +* Web service that allows to retrieve superheroes. +* Superheroes can be [filtered by their powers](#api-documentation) and their true identity can be encrypted + to further provide protection for the ones enrolled in the platform. +* Encryption is done using a proprietary encryption called the “DeeSee Chiffre”. +* The superheroes are stored in a [json file](data/heros.json). +* The application can be configured via [environment variables](#configuration). + +## Prerequisites + +* Golang 1.19 +* Make 4.3 +* Bash 5.1 + +## Configuration + +The application can be configured via environment variables: + +* `PORT` - the port the application will listen on (default: 8080) +* `KEY` - the key used for the encryption (default: 5) +* `DATA_FILE` - the path to the json file containing the superheroes (default: data/superheroes.json) + +## How to run + +* `make run` to run the application +* `make build` to build the application +* `make clean` to clean the application +* `make test` to run the tests + +## API Documentation + +### GET /superheroes + +Returns a list of superheroes. + +#### Parameters + +* (optional) `superpowers` - a comma separated list of superpowers to filter the superheroes by. +* (optional) `encrypted` - if set to `true`, the identity will be encrypted. + +#### Example + +`GET /superheroes?superpowers=strength,flight&encrypted=true` + +#### Result + +```json +[ + { + "name": "superman", + "identity": { + "firstName": "hqfwp", + "lastName": "kent" + }, + "birthday": "1977-04-18", + "superpowers": [ + "flight", + "strength", + "invulnerability" + ] + }, + { + "name": "batman", + "identity": { + "firstName": "hqfwp", + "lastName": "kent" + }, + "birthday": "1977-04-18", + "superpowers": [ + "flight", + "strength", + "invulnerability" + ] + } +] +``` diff --git a/api/router.go b/api/router.go new file mode 100644 index 0000000..37f1722 --- /dev/null +++ b/api/router.go @@ -0,0 +1,173 @@ +package api + +import ( + "encoding/json" + "fmt" + "github.com/eslider/superherohub/pkg/deesee" + "log" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// Router is a custom router that encapsulates the mux.Router +type Router struct { + Heros []*deesee.Superhero // The Heros 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 +} + +// NewRouter 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 Heros 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 NewRouter(heros []*deesee.Superhero, key int, logger *log.Logger) *Router { + // Set the logger + if logger == nil { + logger = log.Default() + } + + r := &Router{ + Heros: 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?encode=deesee +// +// - Retrieve superheroes that match given superpower(s) +// +// GET /superheroes?powers=flight,super-strength +// +// - Retrieve superheroes that match given superpower(s) with encrypted identities +// +// GET /superheroes?powers=flight,super-strength&encode=deesee +func (r *Router) GetSuperHeroes(w http.ResponseWriter, req *http.Request) { + var ( + // Resulting + heroes = r.Heros + // 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.NewSuperhero() + if err = json.NewDecoder(req.Body).Decode(hero); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Check if superhero superpower is acceptable + if !hero.IsAcceptable() { + http.Error(w, fmt.Sprintf("Hero power is not acceptable: %s", hero.Name), http.StatusExpectationFailed) + return + } + + // Prevent duplicate superheroes + if deesee.FindByName(r.Heros, strings.TrimSpace(hero.Name)) != nil { + http.Error(w, fmt.Sprintf("Hero is already exists: %s", hero.Name), http.StatusConflict) + return + } + + r.Heros = append(r.Heros, hero) + + // Return JSON + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, err = w.Write([]byte(`{"status": "ok"}`)) + 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) + }) +} diff --git a/data/alt-heros.json b/data/alt-heros.json new file mode 100644 index 0000000..bd7d3a6 --- /dev/null +++ b/data/alt-heros.json @@ -0,0 +1,111 @@ +[ + { + "name": "Wonder Woman", + "identity": { + "firstName": "Diana", + "lastName": "Prince" + }, + "birthday": "1941-12-01", + "superpowers": [ + "super strength", + "flight", + "combat skills" + ] + }, + { + "name": "Green Lantern", + "identity": { + "firstName": "Hal", + "lastName": "Jordan" + }, + "birthday": "1940-07-22", + "superpowers": [ + "energy projection", + "flight" + ] + }, + { + "name": "Flash", + "identity": { + "firstName": "Barry", + "lastName": "Allen" + }, + "birthday": "1940-03-14", + "superpowers": [ + "super speed", + "time travel" + ] + }, + { + "name": "Aquaman", + "identity": { + "firstName": "Arthur", + "lastName": "Curry" + }, + "birthday": "1941-11-14", + "superpowers": [ + "super strength", + "aquatic adaptation" + ] + }, + { + "name": "Cyborg", + "identity": { + "firstName": "Victor", + "lastName": "Stone" + }, + "birthday": "1980-05-14", + "superpowers": [ + "super strength", + "cybernetic enhancements" + ] + }, + { + "name": "Martian Manhunter", + "identity": { + "firstName": "J'onn", + "lastName": "J'onzz" + }, + "birthday": "1961-11-18", + "superpowers": [ + "shape-shifting", + "telepathy" + ] + }, + { + "name": "Green Arrow", + "identity": { + "firstName": "Oliver", + "lastName": "Queen" + }, + "birthday": "1941-11-01", + "superpowers": [ + "archery", + "wealth" + ] + }, + { + "name": "Batwoman", + "identity": { + "firstName": "Katherine", + "lastName": "Kane" + }, + "birthday": "1956-07-25", + "superpowers": [ + "martial arts", + "detective skills" + ] + }, + { + "name": "Hawkman", + "identity": { + "firstName": "Carter", + "lastName": "Hall" + }, + "birthday": "1940-06-25", + "superpowers": [ + "flight", + "super strength" + ] + } +] diff --git a/data/heros.json b/data/heros.json new file mode 100644 index 0000000..f711734 --- /dev/null +++ b/data/heros.json @@ -0,0 +1,61 @@ +[ + { + "name": "superman", + "identity": { + "firstName": "clark", + "lastName": "kent" + }, + "birthday": "1977-04-18", + "superpowers": [ + "flight", + "strength", + "invulnerability" + ] + }, + { + "name": "deadpool", + "identity": { + "firstName": "wade", + "lastName": "wilson" + }, + "birthday": "1973-11-22", + "superpowers": [ + "healing" + ] + }, + { + "name": "batman", + "identity": { + "firstName": "bruce", + "lastName": "wayne" + }, + "birthday": "1915-04-17", + "superpowers": [ + ] + }, + { + "name": "aquaman", + "identity": { + "firstName": "arthur", + "lastName": "curry" + }, + "birthday": "1986-01-29", + "superpowers": [ + "flight", + "healing", + "strength" + ] + }, + { + "name": "flash", + "identity": { + "firstName": "barry", + "lastName": "allen" + }, + "birthday": "1992-09-30", + "superpowers": [ + "speed", + "healing" + ] + } +] diff --git a/dist/.gitignore b/dist/.gitignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/dist/.gitignore @@ -0,0 +1 @@ +* diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..15bef01 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/eslider/superherohub + +go 1.19 + +require ( + github.com/gorilla/mux v1.8.0 +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..13f4105 --- /dev/null +++ b/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "github.com/eslider/superherohub/api" + "github.com/eslider/superherohub/pkg/deesee" + "github.com/eslider/superherohub/pkg/file" + "log" + "net/http" + "os" + "strconv" + "time" +) + +var ( + dataPath = "data/heros.json" // Heros to JSON file + port = 8080 // Port to serve +) + +// main is the entry point of the application +func main() { + var ( + key = 5 // DeeSee encryption key + err error + ) + + // Create logger + logger := log.Default() + + // Get port from environment variable + if os.Getenv("PORT") != "" { + port, err = strconv.Atoi(os.Getenv("PORT")) + if err != nil { + logger.Fatalf("Invalid port number: %s", err.Error()) + } + if port < 1 || port > 65535 { + logger.Print("Port number must be between 1 and 65535") + logger.Fatalf("Invalid port number : %d", port) + } + } + + // Get key from environment variable + if os.Getenv("KEY") != "" { + key, err = strconv.Atoi(os.Getenv("KEY")) + if err != nil { + logger.Fatalf("Invalid key: %s", err.Error()) + } + //if key < 1 || key > 25 { + // logger.Print("Key number should be between 1 and 25") + // logger.Fatalf("Invalid key number : %d", key) + //} + } else { + logger.Fatalf("Key is not set. Please set KEY environment variable.") + } + + // Get data path from environment variable + if os.Getenv("DATA_PATH") != "" { + dataPath = os.Getenv("DATA_PATH") + } + + // Check if data path exists + if !file.IsExist(dataPath) { + logger.Fatalf("Invalid data path: %s", dataPath) + } + + herous, err := deesee.Load(dataPath) + if err != nil { + logger.Fatalf("Invalid data format: %s", dataPath) + } + + // Create a new server + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), // Define port and address to serve + Handler: api.NewRouter(herous, key, logger).GetHandler(), + ErrorLog: logger, + ReadTimeout: 240 * time.Second, // Good practice: enforcing timeouts for serving. + WriteTimeout: 240 * time.Second, // Good practice: enforcing timeouts for serving. + } + + // Serve and handle error + if err := srv.ListenAndServe(); err != nil { + logger.Fatal(err) + } +} diff --git a/pkg/deesee/codec.go b/pkg/deesee/codec.go new file mode 100644 index 0000000..bc1864d --- /dev/null +++ b/pkg/deesee/codec.go @@ -0,0 +1,75 @@ +// Package deesee contains functions for encrypting and decrypting text. +// +// Important: +// - The encryption is not secure at all, it's just a simple substitution cipher. +// - Only the English language is supported. +// +// Ideas: +// - write an encoder and construct like NewEncoder(key).Encode(any), but this is not required at this stage of the project. +// - improve deesee-encoder to work with annotations, something like `deesee:"encode"` to prevent encoding for not authorized properties +package deesee + +import "strings" + +const ( + a = 97 // ASCII code for a + z = 122 // ASCII code for z +) + +// Encrypt text and returns an encrypted using the "DeeSee Chiffre" encryption. +func Encrypt(text string, n int) string { + return chiffre(text, n) +} + +// Decrypt text and returns a decrypted using the "DeeSee Chiffre" encryption. +func Decrypt(text string, n int) string { + return chiffre(text, -n) +} + +// chiffre function takes a string and a key as input and returns the DeeSee encoded string +func chiffre(text string, key int) string { + var ( + output strings.Builder // String builder to store the encoded string + ascii int // ASCII value of the current character + ) + // + // Convert the text string to lowercase + text = strings.ToLower(text) + + // Let the key be in the range of letters + key %= z - a - 1 + + // Iterate through each character in the text string + for _, char := range text { + // Convert the character to its ASCII value + ascii = int(char) + + // Encode only if character is a letter + if isLetter(ascii) { + // Shift the character by the key + ascii += key + + // Which direction are we shifting? + if key > 0 { + // If the character is now out of bounds + if ascii > z { + ascii = a + key - 2 + } + } else { + // If the character is now out of bounds + if ascii < a { + ascii = z + key + 2 + } + } + } + + // Append the encoded character to the output string + output.WriteRune(rune(ascii)) + } + return output.String() +} + +// isLetter returns true if the given character is inside of ASCII letter diapason. +func isLetter(char int) bool { + return char >= a && char <= z +} diff --git a/pkg/deesee/codec_test.go b/pkg/deesee/codec_test.go new file mode 100644 index 0000000..5a393d5 --- /dev/null +++ b/pkg/deesee/codec_test.go @@ -0,0 +1,36 @@ +package deesee + +import "testing" + +// TestEncrypt tests the Encrypt and Decrypt function. +func TestCryptDecrypt(t *testing.T) { + + // Case is a struct to store test cases. + type Case struct { + name string + key int + A string + B string + } + + // Run the test cases. + for _, tt := range []Case{ + {"Test encrypt-decrypt: simple text", + 5, "clark", "hqfwp"}, + {"Test encrypt-decrypt: text with shifted outbounds chars", + 3, "cherry blossom", "fkhuub eorvvrp"}, + {"Test encrypt-decrypt: text with shifted outbounds chars and non-letter chars", + 3, "cherry123 blossom!", "fkhuub123 eorvvrp!"}, + } { + t.Run(tt.name, func(t *testing.T) { + enc := Encrypt(tt.A, tt.key) + if enc != tt.B { + t.Errorf("Encrypt() = %v, want %v", enc, tt.B) + } + dec := Decrypt(enc, tt.key) + if dec != tt.A { + t.Errorf("Decrypt() = %v, want %v", dec, tt.A) + } + }) + } +} diff --git a/pkg/deesee/filter_test.go b/pkg/deesee/filter_test.go new file mode 100644 index 0000000..10987f6 --- /dev/null +++ b/pkg/deesee/filter_test.go @@ -0,0 +1,39 @@ +package deesee + +import ( + "github.com/eslider/superherohub/pkg/file" + "testing" +) + +// Unit Test Coverage +func TestFilterBySuperPowers(t *testing.T) { + rootPath, err := file.GetModRootPath() + if err != nil { + t.Errorf("Expected GetModuleRootPath to return module root path, got error: %v", err) + return + } + persons, err := Load(rootPath + "/data/heros.json") + if err != nil { + t.Errorf("Expected Load to return persons, got error: %v", err) + return + } + + // Test count loaded persons + if len(persons) < 1 { + t.Errorf("Expexted at least one person, got none") + } + + // Test with valid + if len(SearchByPowers(persons, []string{"healing"})) < 1 { + t.Errorf("Expected at least one person with healing superpower, got none") + } + // Test with valid and invalid + if len(SearchByPowers(persons, []string{"healing", "slowpoking"})) < 1 { + t.Errorf("Expected at least one person with healing superpower, got none") + } + + // Test with invalid + if len(SearchByPowers(persons, []string{"programming"})) > 1 { + t.Errorf("Expected no one person with programming superpower, got some") + } +} diff --git a/pkg/deesee/model.go b/pkg/deesee/model.go new file mode 100644 index 0000000..6a9434a --- /dev/null +++ b/pkg/deesee/model.go @@ -0,0 +1,124 @@ +package deesee + +import ( + "encoding/json" + "github.com/eslider/superherohub/pkg/people" + "os" + "strings" +) + +// superPower is a type for superpowers. +type superPower string + +// List of allowed superpowers. +const ( + strength superPower = "strength" + speed superPower = "speed" + flight superPower = "flight" + invulnerability superPower = "invulnerability" + healing superPower = "healing" +) + +// To keep the system as simple as possible, +// DeeSee has agreed to only accept superheroes with the following superpowers. +var allowedSuperPowers = [5]superPower{strength, speed, flight, invulnerability, healing} + +// Superhero DeeSee person type. +type Superhero people.Person + +// IsAcceptable checks if the superhero is acceptable. +func (s *Superhero) IsAcceptable() (r bool) { + // Check if the person has superpowers. + if *s.SuperPowers == nil { + return false + } + // Check if the person has at least one acceptable superpower. + for _, p := range *s.SuperPowers { + if IsAcceptable(p) { + r = true + break + } + } + return +} + +// IsSuperHero checks if the superhero is acceptable. +func (s *Superhero) IsSuperHero() bool { + return s.IsAcceptable() +} + +// Has checks if the superhero has the given power. +func (s *Superhero) Has(power string) bool { + return s.SuperPowers.Contains(power) +} + +// IsAcceptable checks if the given power is allowed. +func IsAcceptable(power string) bool { + for _, p := range allowedSuperPowers { + if string(p) == power { + return true + } + } + return false +} + +// Load JSON file and decode to `[]*Person` list +func Load(path string) (l []*Superhero, err error) { + var f *os.File + if f, err = os.Open(path); err != nil { + return + } + // Parse the request body + err = json.NewDecoder(f).Decode(&l) + return +} + +// EncryptHerosIdentities encrypts all persons identities with DeeSee encryption algorithm +func EncryptHerosIdentities(heros []*Superhero, key int) (r []*Superhero) { + for _, person := range heros { + p := &Superhero{ + Name: person.Name, + Identity: EncryptIdentity(person.Identity, key), + SuperPowers: person.SuperPowers, + Birthday: person.Birthday, + } + r = append(r, p) + } + return +} + +// EncryptIdentity encrypts person identity with DeeSee encryption algorithm +func EncryptIdentity(identity *people.Identity, key int) *people.Identity { + return &people.Identity{ + FirstName: Encrypt(identity.FirstName, key), + LastName: Encrypt(identity.LastName, key), + } +} + +// SearchByPowers from persons list +func SearchByPowers(heros []*Superhero, powers []string) (r []*Superhero) { + for _, hero := range heros { + for _, power := range powers { + if hero.IsSuperHero() && hero.Has(power) { + r = append(r, hero) + break + } + } + } + return +} + +// NewSuperhero creates a new superhero +func NewSuperhero() *Superhero { + return &Superhero{} +} + +// FindByName from superheros list +func FindByName(heros []*Superhero, name string) *Superhero { + for _, hero := range heros { + if strings.EqualFold(name, hero.Name) { + return hero + } + } + return nil +} diff --git a/pkg/file/utils.go b/pkg/file/utils.go new file mode 100644 index 0000000..206c8ea --- /dev/null +++ b/pkg/file/utils.go @@ -0,0 +1,45 @@ +package file + +// This package makes it easier to implement tests to load data from a files. +// +// NOTE: Although it was not explicitly mentioned in the task, +// I decided to make the GetModRootPath() function return path and an error if `go.mod` was not found. + +import ( + "errors" + "os" + "path/filepath" + "runtime" +) + +// GetModRootPath get the root path of the module +func GetModRootPath() (string, error) { + // Get caller filePath + _, path, _, _ := runtime.Caller(0) + // Get base directory by file info + var prevPath string + for { + path = filepath.Dir(path) + + if IsExist(path + "/go.mod") { + return path, nil + } + // Break if we reach root directory: '/' + // Break if prevPath is same, maybe on Windows, something like C:// or c:\\ + if prevPath == path || len(path) < 2 { + break + } + + prevPath = path + } + return "", errors.New("go.mod not found") +} + +// IsExist the path and isn't directory +func IsExist(path string) bool { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} diff --git a/pkg/people/birthday.go b/pkg/people/birthday.go new file mode 100644 index 0000000..c2ad9a0 --- /dev/null +++ b/pkg/people/birthday.go @@ -0,0 +1,37 @@ +package people + +import ( + "encoding/json" + "time" +) + +// Declaring birthday layout constant +const birthdayLayout = "2006-01-02" // YYYY-MM-DD - is default birthday layout + +// Birthday is a custom encapsulation type for time.Time to handle birthdays +// by decoding and encoding from and to JSON format +type Birthday struct { + time.Time // Embedding time.Time + err error // Time parsing error +} + +// UnmarshalJSON decodes a date text to time.Time object +func (t *Birthday) UnmarshalJSON(bytes []byte) (err error) { + // 1. Decode the bytes into an int64 + // 2. Parse the unix timestamp + + // Is value quoted? + if rune(bytes[0]) == '"' || rune(bytes[0]) == '\'' { + bytes = bytes[1 : len(bytes)-1] // remove quotes + } + + // Parse string to time.Time + t.Time, t.err = time.Parse(birthdayLayout, string(bytes)) + t.err = err + return +} + +// MarshalJSON encodes a time.Time object to date JSON text +func (t Birthday) MarshalJSON() ([]byte, error) { + return json.Marshal(t.Time.Format(birthdayLayout)) +} diff --git a/pkg/people/identity.go b/pkg/people/identity.go new file mode 100644 index 0000000..a777225 --- /dev/null +++ b/pkg/people/identity.go @@ -0,0 +1,7 @@ +package people + +// Identity represents the true identity of a person +type Identity struct { + FirstName string `json:"firstName" deesee:"encode"` + LastName string `json:"lastName" deesee:"encode"` +} diff --git a/pkg/people/person.go b/pkg/people/person.go new file mode 100644 index 0000000..fbea25c --- /dev/null +++ b/pkg/people/person.go @@ -0,0 +1,76 @@ +package people + +import ( + "encoding/json" +) + +// Person data representation. +// +// NOTE: +// +// The reason the people package was created is reusability. +// +// Personalities are used more often and are more general +// in applications than superheroes. +// +// # Some additional reflection: +// +// * Superheroes are a special case of people. +// * Normal persons are not superheros at all, but: +// - An ordinary person, over time, can gain superpowers +// - Do we need to handle them as no superhero? +// - A superhero can lose his superpowers. +// - But he was already using them, so does that mean he's no longer a superhero? +// - Batman, as example has, no superpowers, so he is not a DeeSee "superhero". +// +// Additional to think about, is that People, a man, +// can have many personalities(natural, juridical, fantasy, artistic, fake), +// also superheros can become transformation(magically) to another person(becomes additional personalities), +// so the person becomes more superpowers? (rhetorical question) +// +// . +// Anyway, to work anything about, there is a `Person` a struct which has a Method `IsSuperHero() bool. +type Person struct { + // Name of a Person + Name string `json:"name"` + + // Identity of a Person. + // Sure, people can have many identities, fakes too. + // But at the time we handle only one-to-one person to identity relation + Identity *Identity `json:"identity"` + + // SuperPowers list + SuperPowers *SuperPowers `json:"superpowers"` + + // Birthday formatted as `YYYY-MM-DD` text string, there is no time zone and time. + // The format-Layout is defined in internal `birthdayLayout` constant + Birthday *Birthday `json:"birthday"` +} + +// IsSuperHero the person? +func (p *Person) IsSuperHero() bool { + return p.SuperPowers != nil && len(*p.SuperPowers) > 0 +} + +// Has person the superpower? +// In other words: is the person a superhero at all? +func (p *Person) Has(power string) bool { + // The excessive use of the IsSuperHero() method is intentional, but for clarity, + // because it better describes what's going on. + // + // In the real world we deal with optimization of methods + // and of course it's better not to do so... + return p.IsSuperHero() && p.SuperPowers.Contains(power) +} + +// Marshal marshals the person to JSON +func (p *Person) Marshal() ([]byte, error) { + return json.Marshal(p) + +} + +func Unmarshal(js string) (p *Person, err error) { + p = &Person{} + err = json.Unmarshal([]byte(js), p) + return +} diff --git a/pkg/people/person_test.go b/pkg/people/person_test.go new file mode 100644 index 0000000..27d06d5 --- /dev/null +++ b/pkg/people/person_test.go @@ -0,0 +1,48 @@ +package people + +import ( + "strings" + "testing" +) + +// TestPersonModel tests the Unmarshalling and properties. +func TestPersonModel(t *testing.T) { + p, err := Unmarshal(`{"name": "batman","identity": {"firstName": "bruce","lastName": "wayne"},"birthday": "1915-04-17"}`) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if p.Name != "batman" || + p.Identity.FirstName != "bruce" || + p.Identity.LastName != "wayne" || + p.Birthday.Year() != 1915 { + t.Fatalf("Unmarshal() error") + } + + if p.IsSuperHero() { + t.Fatalf("Batman has no superpowers") + } + + js, err := p.Marshal() + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + if !strings.Contains(string(js), `"birthday":"1915-04-17"`) { + t.Fatalf("Marshal() birthday error") + } + + // Batman with superpowers + p, err = Unmarshal(`{"name": "batman","identity": {"firstName": "bruce","lastName": "wayne"},"birthday": "1915-04-17","superpowers": ["money","intelligence"]}`) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if !p.IsSuperHero() || !p.Has("money") || !p.Has("intelligence") { + t.Fatalf("Batman has some superpowers as well") + } + + if p.Has("flight") { + t.Fatalf("Batman has no superpowers") + } +} diff --git a/pkg/people/power.go b/pkg/people/power.go new file mode 100644 index 0000000..876c194 --- /dev/null +++ b/pkg/people/power.go @@ -0,0 +1,19 @@ +package people + +import "strings" + +// SuperPowers list of an SuperHero. +// Normal person has no super-power? +// Perhaps we just don't know enough to distinguish the qualities in each individual person. Sad but true. +type SuperPowers []string + +// Contains superpower list the strength? +// Not go idiomatic? Okay, we can discuss it. +func (s *SuperPowers) Contains(power string) bool { + for _, p := range *s { + if strings.EqualFold(p, power) { + return true + } + } + return false +} diff --git a/router_test.go b/router_test.go new file mode 100644 index 0000000..8407af5 --- /dev/null +++ b/router_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "github.com/eslider/superherohub/api" + "github.com/eslider/superherohub/pkg/deesee" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestWebserviceGetSuperHeros tests the HandleWebserviceRequest function +func TestWebserviceGetSuperHeros(t *testing.T) { + // Load heros + heros, err := deesee.Load(dataPath) + if err != nil { + t.Fatalf("Error loading heros: %heros", err) + } + + // Create router + router := api.NewRouter(heros, 5, nil) + + // Test cases + for _, tt := 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"}}, + } { + // Test with valid domain model + req, err := http.NewRequest(http.MethodGet, tt.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 tt.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 tt.filterBy != nil { + for _, wantPower := range *tt.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 domain models +func TestWebserviceStoreSuperhero(t *testing.T) { + // Load heros + heros, err := deesee.Load(dataPath) + if err != nil { + t.Fatalf("Error loading heros: %heros", err) + } + + // Create router + router := api.NewRouter(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")) + } +}