From 03c338ef48e79fd80e33006f78cfe00389dbd817 Mon Sep 17 00:00:00 2001 From: anthonysyk Date: Fri, 6 Jan 2023 00:07:33 +0100 Subject: [PATCH] feature/generate-router-from-config-file --- README.md | 87 ++++++++++---- conf.go | 17 +-- example/conf/conf_test.go | 139 +++++++++++++++++++++-- example/conf/payload.go | 20 ++++ example/conf/payload/movies/suggest.json | 10 ++ example/conf/routes.yml | 32 ++++-- go.mod | 1 + go.sum | 2 + pkg/routehelper/routehelper.go | 44 +++++++ pkg/routehelper/routehelper_test.go | 43 +++++++ pkg/stdout/stdout.go | 21 ++++ router.go | 37 ++++++ routes.go | 41 ++++++- server.go | 1 - 14 files changed, 439 insertions(+), 56 deletions(-) create mode 100644 example/conf/payload.go create mode 100644 example/conf/payload/movies/suggest.json create mode 100644 pkg/routehelper/routehelper.go create mode 100644 pkg/routehelper/routehelper_test.go create mode 100644 pkg/stdout/stdout.go create mode 100644 router.go delete mode 100644 server.go diff --git a/README.md b/README.md index 940c6d5..c748e52 100644 --- a/README.md +++ b/README.md @@ -10,45 +10,84 @@ version: 1.0 paths: /health: GET: - responses: - 200: - body: | - {"status":"OK"} - /recommended-movies: + 200: + body: |- + {"status":"OK"} + /top-movies: GET: - responses: - 200: - filepath: "payload/top-movies.json" + 200: + filepath: "payload/top-movies.json" /movies/{id}: GET: - responses: - 200: - filepath: "payload/movies/{id}.json" - /movies/{id}/actors/{id}: + 200: + filepath: "payload/movies/{id}.json" + /movies/{moviesId}/actors/{actorsId}: GET: - responses: - 200: - filepath: "payload/movies/{id}/actors/{id}.json" - /popular-actors: + 200: + filepath: "payload/movies/{moviesId}/actors/{actorsId}.json" + /movies/suggest: GET: - responses: - 200: - filepath: "payload/top-actors.json" + 200: + body: |- + { + "title": "The Godfather", + "description": "The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.", + "duration": 175, + "actors": [ + "Marlon Brando", + "Al Pacino", + "James Caan" + ] + } + /top-actors: + GET: + 200: + filepath: "payload/top-actors.json" /genres: GET: - responses: - 200: - body: | - ["Action","Adventure","Animation","Comedy","Crime","Drama","Family","Fantasy","Music","Science Fiction","Thriller"] + 200: + body: |- + ["Action","Adventure","Animation","Comedy","Crime","Drama","Family","Fantasy","Music","Science Fiction","Thriller"] + POST: + 200: + body: |- + {"message":"new genre added"} + DELETE: + 200: + body: |- + {"message":"genre deleted"} ``` You can either set data as : - a filepath - an inline string +### Usage + +```go +//go:embed routes.yml +var RoutesYAML string + +func main() { + router, err := http_server_mock.GenerateRouter(RoutesYAML) + if err != nil { + // handle error + } + http.ListenAndServe(":8080", router) +} +``` + ### Use Cases - To run a mock http server for developing, testing or debugging with mocked data - To mock an API you don't have access to - To mock response payloads and match a specific environment -- To implement integration tests \ No newline at end of file +- To implement integration tests + +### Troubleshoot + +Warnings can be raised if : +- Path parameters definitions must be unique per URL + - Good : `/movies/{moviesId}/actors/{actorsId}` + - Bad : `/movies/{id}/actors/{id}` +- Routes must be defined only once (no duplicate) diff --git a/conf.go b/conf.go index 6008959..c6c2718 100644 --- a/conf.go +++ b/conf.go @@ -3,7 +3,6 @@ package http_server_mock import ( "errors" "gopkg.in/yaml.v3" - "os" ) func GetRoutes(config string) (Routes, error) { @@ -19,12 +18,12 @@ func GetRoutes(config string) (Routes, error) { for _, mt := range methodTails { statusTails := getStatus(mt.Tail) for _, st := range statusTails { - filepath, body, bodyErr := getBody(st.Tail) + filepath, body, bodyErr := getFilepathOrInlineBody(st.Tail) routes = append(routes, Route{ URL: ut.URL, Method: mt.Method, StatusCode: st.Status, - Body: body, + InlineBody: body, Filepath: filepath, Errors: []error{bodyErr}, }) @@ -85,20 +84,16 @@ func getStatus(m map[interface{}]interface{}) []StatusTail { return statusTails } -func getBody(m map[string]interface{}) (string, []byte, error) { +func getFilepathOrInlineBody(m map[string]interface{}) (string, []byte, error) { if len(m) > 1 { return "", nil, errors.New("cannot set both body and filepath for a specific status code") } - if body, ok := m["body"]; ok && body != "" { + if body, ok := m["body"]; ok && body != "" && body != nil { return "", []byte(body.(string)), nil } - if filepath, ok := m["filepath"]; ok && filepath != "" { + if filepath, ok := m["filepath"]; ok && filepath != "" && filepath != nil { fp := filepath.(string) - content, err := os.ReadFile(fp) - if err != nil { - return fp, nil, err - } - return fp, content, nil + return fp, nil, nil } return "", nil, errors.New("no body found") diff --git a/example/conf/conf_test.go b/example/conf/conf_test.go index ad2d491..410fbfa 100644 --- a/example/conf/conf_test.go +++ b/example/conf/conf_test.go @@ -1,23 +1,142 @@ package conf import ( + _ "embed" + "fmt" "github.com/anthonysyk/http-server-mock" + "github.com/anthonysyk/http-server-mock/pkg/routehelper" + "github.com/anthonysyk/http-server-mock/pkg/stdout" "github.com/stretchr/testify/assert" + "io" + "net/http" + "strings" "testing" - - _ "embed" ) -//go:embed routes.yml -var RoutesYAML string - -func TestModel(t *testing.T) { +func TestRoutes(t *testing.T) { routes, err := http_server_mock.GetRoutes(RoutesYAML) assert.NoError(t, err) assert.Len(t, routes, 8) } -// run server -// do some calls -// catch output stdout -// verify output is ok +func TestRouter(t *testing.T) { + router, err := http_server_mock.GenerateRouter(RoutesYAML) + assert.NoError(t, err) + output := stdout.Record(func() { routehelper.PrintRoutes(router) }) + expectedOutput := `[GET] /movies/{id} - http-server-mock.handler.func1 +[GET] /movies/{moviesId}/actors/{actorsId} - http-server-mock.handler.func1 +[GET] /top-actors - http-server-mock.handler.func1 +[DELETE] /genres - http-server-mock.handler.func1 +[GET] /genres - http-server-mock.handler.func1 +[POST] /genres - http-server-mock.handler.func1 +[GET] /health - http-server-mock.handler.func1 +[GET] /top-movies - http-server-mock.handler.func1 +` + assert.ElementsMatch(t, strings.Split(expectedOutput, "\n"), strings.Split(output, "\n")) +} + +//go:embed routes.yml +var RoutesYAML string + +func TestServer(t *testing.T) { + router, err := http_server_mock.GenerateRouter(RoutesYAML) + assert.NoError(t, err) + go http.ListenAndServe(":8080", router) + + client := &http.Client{} + + tests := []struct { + url string + method string + expectedStatusCode int + expectedResponse string + }{ + { + url: "/health", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: `{"status":"OK"}`, + }, + { + url: "/genres", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: `["Action","Adventure","Animation","Comedy","Crime","Drama","Family","Fantasy","Music","Science Fiction","Thriller"]`, + }, + { + url: "/genres", + method: http.MethodPost, + expectedStatusCode: 200, + expectedResponse: `{"message":"new genre added"}`, + }, + { + url: "/genres", + method: http.MethodDelete, + expectedStatusCode: 200, + expectedResponse: `{"message":"genre deleted"}`, + }, + { + url: "/top-movies", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: TopMoviesPayload, + }, + { + url: "/top-actors", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: TopActorsPayload, + }, + { + url: "/movies/399566", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: Movie399566Payload, + }, + { + url: "/movies/399566/actors/15556", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: Movie399566Actor15556Payload, + }, + { + url: "/movies/suggest", + method: http.MethodGet, + expectedStatusCode: 200, + expectedResponse: MovieSuggestPayload, + }, + { + url: routehelper.ReplaceQueryParamWithID("/movies/{id}"), + method: http.MethodGet, + expectedStatusCode: 404, + }, + { + url: routehelper.ReplaceQueryParamWithID("/movies/{id}/actors/{id}"), + method: http.MethodGet, + expectedStatusCode: 404, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%s%s", test.method, test.url), func(t *testing.T) { + url := fmt.Sprintf("%s%s", "http://localhost:8080", test.url) + req, reqErr := http.NewRequest(test.method, url, nil) + if reqErr != nil { + t.Fatal(reqErr) + } + res, resErr := client.Do(req) + defer res.Body.Close() + if resErr != nil { + t.Fatal(resErr) + } + body, bodyErr := io.ReadAll(res.Body) + if bodyErr != nil { + t.Fatal(bodyErr) + } + + assert.Equal(t, test.expectedStatusCode, res.StatusCode) + assert.Equal(t, test.expectedResponse, string(body)) + }) + } + +} diff --git a/example/conf/payload.go b/example/conf/payload.go new file mode 100644 index 0000000..a4cb1f5 --- /dev/null +++ b/example/conf/payload.go @@ -0,0 +1,20 @@ +package conf + +import ( + _ "embed" +) + +//go:embed payload/top-movies.json +var TopMoviesPayload string + +//go:embed payload/top-actors.json +var TopActorsPayload string + +//go:embed payload/movies/399566.json +var Movie399566Payload string + +//go:embed payload/movies/399566/actors/15556.json +var Movie399566Actor15556Payload string + +//go:embed payload/movies/suggest.json +var MovieSuggestPayload string diff --git a/example/conf/payload/movies/suggest.json b/example/conf/payload/movies/suggest.json new file mode 100644 index 0000000..d18c92e --- /dev/null +++ b/example/conf/payload/movies/suggest.json @@ -0,0 +1,10 @@ +{ + "title": "The Godfather", + "description": "The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.", + "duration": 175, + "actors": [ + "Marlon Brando", + "Al Pacino", + "James Caan" + ] +} \ No newline at end of file diff --git a/example/conf/routes.yml b/example/conf/routes.yml index a6fbde7..a88b262 100644 --- a/example/conf/routes.yml +++ b/example/conf/routes.yml @@ -3,9 +3,9 @@ paths: /health: GET: 200: - body: | + body: |- {"status":"OK"} - /recommended-movies: + /top-movies: GET: 200: filepath: "payload/top-movies.json" @@ -13,24 +13,38 @@ paths: GET: 200: filepath: "payload/movies/{id}.json" - /movies/{id}/actors/{id}: + /movies/{moviesId}/actors/{actorsId}: GET: 200: - filepath: "payload/movies/{id}/actors/{id}.json" - /popular-actors: + filepath: "payload/movies/{moviesId}/actors/{actorsId}.json" + /movies/suggest: + GET: + 200: + body: |- + { + "title": "The Godfather", + "description": "The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.", + "duration": 175, + "actors": [ + "Marlon Brando", + "Al Pacino", + "James Caan" + ] + } + /top-actors: GET: 200: filepath: "payload/top-actors.json" /genres: GET: 200: - body: | + body: |- ["Action","Adventure","Animation","Comedy","Crime","Drama","Family","Fantasy","Music","Science Fiction","Thriller"] POST: 200: - body: | + body: |- {"message":"new genre added"} DELETE: 200: - body: | - {"message": "genre deleted" } + body: |- + {"message":"genre deleted"} diff --git a/go.mod b/go.mod index 64d4236..655a88e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/anthonysyk/http-server-mock go 1.19 require ( + github.com/gorilla/mux v1.8.0 github.com/stretchr/testify v1.8.1 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 2ec90f7..341d5c2 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ 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/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/pkg/routehelper/routehelper.go b/pkg/routehelper/routehelper.go new file mode 100644 index 0000000..d7a634c --- /dev/null +++ b/pkg/routehelper/routehelper.go @@ -0,0 +1,44 @@ +package routehelper + +import ( + "fmt" + "github.com/gorilla/mux" + "math/rand" + "path" + "reflect" + "regexp" + "runtime" + "strings" + "time" +) + +func PrintRoutes(router *mux.Router) { + router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { + pathTemplate, err := route.GetPathTemplate() + if err == nil { + methods, _ := route.GetMethods() + handlerName := runtime.FuncForPC(reflect.ValueOf(route.GetHandler()).Pointer()).Name() + for _, m := range methods { + fmt.Printf("[%s] %s - %s\n", m, pathTemplate, path.Base(handlerName)) + } + } + return err + }) +} + +func ReplaceQueryParamWithID(s string) string { + rand.Seed(time.Now().UnixNano()) + re := regexp.MustCompile(`\{[^}]+\}`) + return re.ReplaceAllStringFunc(s, func(match string) string { + return fmt.Sprintf("%d", rand.Intn(1000000)+1) + }) +} + +func ReplaceQueryParamsPlaceholdersWithValues(s string, values map[string]string) string { + re := regexp.MustCompile(`\{[^}]+\}`) + return re.ReplaceAllStringFunc(s, func(match string) string { + key := strings.Replace(match, "{", "", 1) + key = strings.Replace(key, "}", "", 1) + return fmt.Sprintf(values[key]) + }) +} diff --git a/pkg/routehelper/routehelper_test.go b/pkg/routehelper/routehelper_test.go new file mode 100644 index 0000000..60eb0de --- /dev/null +++ b/pkg/routehelper/routehelper_test.go @@ -0,0 +1,43 @@ +package routehelper + +import ( + "github.com/anthonysyk/http-server-mock/pkg/stdout" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +func TestPrintRoutes(t *testing.T) { + router := mux.NewRouter() + router.HandleFunc("/route1", func(w http.ResponseWriter, r *http.Request) {}).Methods("GET") + router.HandleFunc("/route2", func(w http.ResponseWriter, r *http.Request) {}).Methods("POST") + + output := stdout.Record(func() { PrintRoutes(router) }) + + expected := `[GET] /route1 - routehelper.TestPrintRoutes.func1 +[POST] /route2 - routehelper.TestPrintRoutes.func2 +` + assert.Equal(t, expected, output) +} + +func TestReplaceQueryParamWithID(t *testing.T) { + url := "/movies/{id}/actors/{id}" + result := ReplaceQueryParamWithID(url) + assert.NotEqual(t, url, result) +} + +func TestReplaceQueryParamsPlaceholdersWithValues(t *testing.T) { + url := "/movies/{movieId}/actors/{actorId}" + path := "payload/movies/{movieId}/actors/{actorId}.json" + + values := make(map[string]string) + values["movieId"] = "123" + values["actorId"] = "456" + + resultURL := ReplaceQueryParamsPlaceholdersWithValues(url, values) + assert.Equal(t, "/movies/123/actors/456", resultURL) + + resultPath := ReplaceQueryParamsPlaceholdersWithValues(path, values) + assert.Equal(t, "payload/movies/123/actors/456.json", resultPath) +} diff --git a/pkg/stdout/stdout.go b/pkg/stdout/stdout.go new file mode 100644 index 0000000..9b71713 --- /dev/null +++ b/pkg/stdout/stdout.go @@ -0,0 +1,21 @@ +package stdout + +import ( + "io" + "os" +) + +func Record(fn func()) string { + old := os.Stdout + // start recording stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + // end recording + _ = w.Close() + result, _ := io.ReadAll(r) + os.Stdout = old + return string(result) +} diff --git a/router.go b/router.go new file mode 100644 index 0000000..c86c558 --- /dev/null +++ b/router.go @@ -0,0 +1,37 @@ +package http_server_mock + +import ( + "fmt" + "github.com/anthonysyk/http-server-mock/pkg/routehelper" + "github.com/gorilla/mux" + "net/http" +) + +func GenerateRouter(config string) (*mux.Router, error) { + router := mux.NewRouter() + + routes, err := GetRoutes(config) + if err != nil { + return nil, err + } + + for _, r := range routes { + router.HandleFunc(r.URL, handler(r)).Methods(r.Method) + } + + routehelper.PrintRoutes(router) + return router, nil +} + +func handler(route Route) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + content, err := route.GetBody(mux.Vars(r)) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(route.StatusCode) + fmt.Fprintf(w, string(content)) + } +} diff --git a/routes.go b/routes.go index a33ff63..2c488a7 100644 --- a/routes.go +++ b/routes.go @@ -1,12 +1,51 @@ package http_server_mock +import ( + "fmt" + "github.com/anthonysyk/http-server-mock/pkg/routehelper" + "os" +) + type Routes []Route type Route struct { URL string Method string StatusCode int - Body []byte + InlineBody []byte Filepath string Errors []error } + +func (r Route) GetStatusCode() int { + return r.StatusCode +} + +func (r Route) GetBody(pathParams map[string]string) ([]byte, error) { + if r.InlineBody != nil { + return r.InlineBody, nil + } + + body, err := r.filepathBody(pathParams) + if err != nil { + return nil, err + } + + return body, nil +} + +func (r Route) filepathBody(pathParams map[string]string) ([]byte, error) { + filepath := routehelper.ReplaceQueryParamsPlaceholdersWithValues(r.Filepath, pathParams) + fmt.Println(filepath) + content, err := os.ReadFile(filepath) + if err != nil { + return nil, err + } + return content, nil +} + +// todo : path param non unique, method http exists and uppercase +func (rs Routes) Validate() []error { + + return nil +} diff --git a/server.go b/server.go deleted file mode 100644 index 31c6401..0000000 --- a/server.go +++ /dev/null @@ -1 +0,0 @@ -package http_server_mock