diff --git a/README.md b/README.md index 863525d..7c22e72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # demo-api -A simple command-line JSON server. If you need to stub out an API locally, this is the tool you need. It's lightweight, works on Mac, Windows, and Linux, and requires no setup. +A simple command-line JSON server that also emulates a REST API. If you need to stub out an API locally, this is the tool you need. It's lightweight, works on Mac, Windows, and Linux, and requires no setup other than a JSON data file. + +## Usage Create a JSON file called `data.json`: @@ -19,17 +21,106 @@ Create a JSON file called `data.json`: } ``` -Then run `demo-api`. +Then run `demo-api`. The server starts up. -Visit `http://localhost:8080` in the browser to see the file served as JSON. +Visit `http://localhost:8080` in the browser to serve the entire file as a JSON response. -In addition, since the JSON file is structured like a typical REST API data structure, the following will work: +In addition, since the JSON file is structured like a typical REST API data structure, the following endpoints will work: * `GET http://localhost:8080/notes` - retrive all "notes" * `GET http://localhost:8080/notes/1` - retrive the note with the `id` of `1` -* `POST http://localhost:8080/notes/` - Create a new note. This modifies the `data.json` file. +* `POST http://localhost:8080/notes/` with JSON payload - Create a new note. This modifies the `data.json` file. +* `PUT/PATCH http://localhost:8080/notes/1` with JSON payload - update the note with the `id` of `1`. This modifies the `data.json` file. * `DELETE http://localhost:8080/notes/1` - Delete the note with the `id` of `1`. This modifies the `data.json` file. + +Anything that doesn't match returns a `404` status code. + +## Examples with `curl` + +Given the following data file: + + +```json +{ + "notes" : [ + { + "id" : 1, + "title" : "Hello World" + }, + { + "id" : 2, + "title" : "The second note" + } + ] +} +``` + +To get everything: + +``` +curl -i localhost:8080/ +``` + +To get the `notes` node: + + +``` +curl -i localhost:8080/notes +``` + +To get the `notes/1` node: + + +``` +curl -i localhost:8080/notes/1 +``` + +To add a new note: + +``` +curl -i -X POST http://localhost:8080/notes \ +-H "Content-type: application/json" \ +-d '{"title": "This is another note"}' +``` + +To update the contents of the first note: + +``` +curl -i -X PUT http://localhost:3000/notes/1 \ +-H "Content-type: application/json" \ +-d '{"title": "This is the third note"}' +``` + +To delete the third note: + +``` +curl -i -X DELETE localhost:3000/notes/3 +``` + +If you use a different JSON file, your paths will be different. + +### Advanced Usage + +To specify a different port, use the `-p` option: + +``` +demo-api -p 4000 +``` + +To specify a different filename, in case you don't like `data.json` as the default, use `-f` and specify the file: + + +``` +demo-api -f notes.json +``` + +To view the version, use `-v`: + +``` +demo-api -v +``` + ## Installation To install, download the latest release to your system and copy the executable to a location on your path. Then launch it in a directory containing `data.json`. @@ -37,9 +128,8 @@ To install, download the latest release to your system and copy the executable t ## Roadmap -* `PUT/PATCH` support -* tests -* refactoring +* Refactoring. This code is a mess. +* A "no persist" mode - changes are accepted but not saved to the JSON file. ## Contributing @@ -56,18 +146,20 @@ $ go get github.com/codegangsta/gin Run development version: ``` -$ gin go run app.go +$ gin --appPort 8080 go run app.go ``` The server is now listening on `localhost:3000` and will reload on code change. -Make changes, create a PR. +Make changes, run the tests, create a PR. ## History -* 2018-09-02 - v0.2.0 +* 2018-09-03 - v0.3.0 * Refactoring to make testing possible * Adds test suite + * Pretty print + * Supports `PUT` and `PATCH` * Supports `-v` option to show version * Supports `-f` option to specify the data file * Supports `-p` option to specify the port diff --git a/app.go b/app.go index fbbc5e5..3239445 100644 --- a/app.go +++ b/app.go @@ -18,7 +18,7 @@ var dataFile string func main() { - listenPort := flag.Int("p", 8000, "The listening port - defaults to 8080.") + listenPort := flag.Int("p", 8080, "The listening port - defaults to 8080.") file := flag.String("f", "./data.json", "The JSON file to load- defaults to ./data.json.") version := flag.Bool("v", false, "Display the current version") flag.Parse() @@ -58,6 +58,8 @@ func setupAPI() *gin.Engine { r.GET("/:name/:id", getById) r.POST("/:name", create) r.DELETE("/:name/:id", deleteById) + r.PUT("/:name/:id", update) + r.PATCH("/:name/:id", update) // routes for js and cors r.OPTIONS("/:name", accessControlHeaders) @@ -82,63 +84,82 @@ func Cors() gin.HandlerFunc { // parse it and then print it back out. func getJSON(c *gin.Context) { code := 200 - jsonParsed, err := getData() + c.Writer.Header().Set("Content-Type", "application/json") + + records, err := getData() if err != nil { code = 404 + c.String(code, records.String()) + } else { + c.String(code, records.StringIndent("", " ")) } - c.Writer.Header().Set("Content-Type", "application/json") - c.String(code, jsonParsed.String()) } // GET /:name // Displays the json records for the resource func getAll(c *gin.Context) { code := 200 + result := "" + jsonParsed, err := getData() if err != nil { code = 404 } name := c.Params.ByName("name") - result := jsonParsed.S(name).String() + + record := jsonParsed.S(name) + + if recordNotFound(record) { + code = 404 + result = record.String() + } else { + result = record.StringIndent("", " ") + } + c.Writer.Header().Set("Content-Type", "application/json") c.String(code, result) + } // GET /:name/:id // Displays the json record for the given id func getById(c *gin.Context) { code := 200 + result := "" + name := c.Params.ByName("name") id := c.Params.ByName("id") - result := "{}" - - c.Writer.Header().Set("Content-Type", "application/json") record, err := findById(name, id) if err != nil { code = 404 + result = record.String() + } else { + result = record.StringIndent("", " ") } - result = record.String() - + c.Writer.Header().Set("Content-Type", "application/json") c.String(code, result) + } // DELETE /:name/:id // Displays the json record for the given id func deleteById(c *gin.Context) { + code := 200 + result := "" + name := c.Params.ByName("name") id := c.Params.ByName("id") - result := "{}" - code := 200 record, err := removeById(name, id) if err != nil { code = 422 - } else { result = record.String() + } else { + result = record.StringIndent("", " ") code = 200 } @@ -149,6 +170,7 @@ func deleteById(c *gin.Context) { // POST /:name // Create a new resource and persist it func create(c *gin.Context) { + result := "" code := 201 collectionName := c.Params.ByName("name") @@ -160,9 +182,39 @@ func create(c *gin.Context) { if err != nil { code = 422 + result = newRecord.String() + } else { + result = newRecord.StringIndent("", " ") } - c.String(code, newRecord.String()) + c.Writer.Header().Set("Content-Type", "application/json") + c.String(code, result) + +} + +// PUT /:name/:id +// update an existing resource and persist it +func update(c *gin.Context) { + collectionName := c.Params.ByName("name") + id := c.Params.ByName("id") + result := "{}" + code := 200 + + // Read data from body and parse it out + data, _ := ioutil.ReadAll(c.Request.Body) + + record, err := updateById(collectionName, id, data) + + if err != nil { + code = 422 + result = record.String() + } else { + code = 200 + result = record.StringIndent("", " ") + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.String(code, result) } @@ -227,11 +279,7 @@ func findById(name string, idString string) (*gabs.Container, error) { } } - // if the result is an empty json response then we didn't find anything. - // I bet there's a better way. But the gabs.Container always - // returns an empty JSON string if it doesn't match. So this works - // for now. - if result.String() == "{}" { + if recordNotFound(result) { return result, fmt.Errorf("json: no record found for id %g", id) } @@ -280,7 +328,7 @@ func removeById(name string, idString string) (*gabs.Container, error) { err = jsonParsed.ArrayRemove(indexToDelete, name) if err == nil { - saveData(jsonParsed) + err = saveData(jsonParsed) } } @@ -289,6 +337,63 @@ func removeById(name string, idString string) (*gabs.Container, error) { } +func updateById(name string, idString string, data []byte) (*gabs.Container, error) { + + recordToUpdate, err := gabs.ParseJSON([]byte(data)) + + if err != nil { + return recordToUpdate, err + } + + indexToUpdate := -1 + + id, err := toFloat(idString) + + if err != nil { + return recordToUpdate, err + } else { + + // take the ID and set it on the record to update + recordToUpdate.Set(id, "id") + + jsonParsed, err := getData() + + if err != nil { + return recordToUpdate, err + } + + // find children for the collection + children, _ := jsonParsed.S(name).Children() + + // find the index of the record we have to delete + for index, child := range children { + + // if we find it.... + if child.S("id").Data().(float64) == id { + // save the index + indexToUpdate = index + break + } + } + + // if we didn't find the record.... + if indexToUpdate == -1 { + return recordToUpdate, fmt.Errorf("json: no record found for id %g", id) + } else { + + // use the index to update the record at the specified index + _, err := jsonParsed.S(name).SetIndex(recordToUpdate.Data(), indexToUpdate) + + if err == nil { + err = saveData(jsonParsed) + } + } + + return recordToUpdate, err + } + +} + // creates a new JSON record and adds it to the collection. func createNewRecord(collectionName string, data []byte) (*gabs.Container, error) { // parse the body to a new record. @@ -322,19 +427,12 @@ func getData() (*gabs.Container, error) { // TODO: probably not super efficient to read the file all the time but // I don't know another way to make this global right now. - // TODO: I bet gabs can read json right from a file. I should explore that - data, err := ioutil.ReadFile(dataFile) - if err != nil { - fmt.Print("No data file found. Exiting.\n") - os.Exit(1) - } - - return gabs.ParseJSON([]byte(data)) + return gabs.ParseJSONFile(dataFile) } // save json back to file. func saveData(json *gabs.Container) error { - data := []byte(json.String()) + data := []byte(json.StringIndent("", " ")) return ioutil.WriteFile(dataFile, data, 0644) } @@ -342,3 +440,12 @@ func saveData(json *gabs.Container) error { func toFloat(id string) (float64, error) { return strconv.ParseFloat(id, 64) } + +// check to see if records found +func recordNotFound(collection *gabs.Container) bool { + // if the result is an empty json response then we didn't find anything. + // I bet there's a better way. But the gabs.Container always + // returns an empty JSON string if it doesn't match. So this works + // for now. + return collection.String() == "{}" +} diff --git a/app_test.go b/app_test.go index 0a72f46..0344777 100644 --- a/app_test.go +++ b/app_test.go @@ -1,7 +1,7 @@ package main import ( - "fmt" + "bytes" "io" "net/http" "net/http/httptest" @@ -16,15 +16,25 @@ func setupDataFileForTest() { } // this is ridiculous. this is how to copy a file in go. -func copy(src string, dest string) { - from, _ := os.Open(src) - defer from.Close() +func copy(src string, dest string) error { + in, err := os.Open(src) - to, _ := os.OpenFile(dest, os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + return err + } + defer in.Close() - defer to.Close() + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() - io.Copy(to, from) + _, err = io.Copy(out, in) + if err != nil { + return err + } + return out.Close() } func TestRootRoute(t *testing.T) { @@ -52,6 +62,20 @@ func TestCollectionRoute(t *testing.T) { t.Errorf("Response was incorrect, got: %d, want: 200.", w.Code) } } + +func TestCollectionRouteWhenNoneFound(t *testing.T) { + setupDataFileForTest() + router := setupAPI() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/derp", nil) + router.ServeHTTP(w, req) + + if w.Code != 404 { + t.Errorf("Response was incorrect, got: %d, want: 404.", w.Code) + } +} + func TestCollectionRecordRoute(t *testing.T) { setupDataFileForTest() router := setupAPI() @@ -78,6 +102,51 @@ func TestCollectionRecordRouteForNonExistantID(t *testing.T) { } } +func TestCreateRecord(t *testing.T) { + setupDataFileForTest() + router := setupAPI() + + var jsonStr = []byte(`{"title":"new record"}`) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/notes", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != 201 { + t.Errorf("Response was incorrect, got: %d, want: 201.", w.Code) + } +} + +func TestUpdateRecordViaPut(t *testing.T) { + setupDataFileForTest() + router := setupAPI() + + var jsonStr = []byte(`{"title":"new record"}`) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/notes/1", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Errorf("Response was incorrect, got: %d, want: 200.", w.Code) + } +} + +func TestUpdateRecordViaPatch(t *testing.T) { + setupDataFileForTest() + router := setupAPI() + + var jsonStr = []byte(`{"title":"new record"}`) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", "/notes/1", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Errorf("Response was incorrect, got: %d, want: 200.", w.Code) + } +} + // unit tests func TestFindByIdWithExistingRecord(t *testing.T) { @@ -103,9 +172,8 @@ func TestFindByIdWithNonInt(t *testing.T) { func TestFindByIdWithNonExistingRecord(t *testing.T) { setupDataFileForTest() - result, err := findById("notes", "1234") + _, err := findById("notes", "1234") - fmt.Print(result.String()) if err == nil { t.Errorf("Should be error when doesn't exist") } @@ -115,9 +183,19 @@ func TestRemoveRecordByIdWorks(t *testing.T) { setupDataFileForTest() - result, _ := removeById("notes", "1") + removeById("notes", "1") + + // see if it persisted + results, _ := getData() + children, _ := results.S("notes").Children() + + for _, child := range children { - fmt.Print(result.S("id").Data().(float64)) + if child.S("id").Data().(float64) == 1 { + t.Errorf("New record still found in the json - failed") + break + } + } } @@ -134,9 +212,9 @@ func TestRemoveRecordByIdWithInvalidID(t *testing.T) { } func TestCreateNewRecord(t *testing.T) { + setupDataFileForTest() var result interface{} - setupDataFileForTest() createNewRecord("notes", []byte(`{"title" : "test"}`)) // see if it persisted @@ -158,3 +236,53 @@ func TestCreateNewRecord(t *testing.T) { t.Errorf("New record wasn't found in the json - failed") } } + +func TestUpdateRecordAtID(t *testing.T) { + + setupDataFileForTest() + + var result = false + + updateById("notes", "1", []byte(`{"title" : "blah blah blah"}`)) + + // see if it persisted + records, _ := getData() + children, _ := records.S("notes").Children() + + // find the index of the record we have to delete + for _, child := range children { + + // if we find it.... + if child.S("id").Data().(float64) == 1 && child.S("title").Data().(string) == "blah blah blah" { + // save the record we found as the result along with the index + result = true + break + } + } + + if !result { + t.Errorf("New record wasn't found in the json - failed") + } +} + +func TestUpdateRecordAtBadID(t *testing.T) { + + setupDataFileForTest() + + _, err := updateById("notes", "1234", []byte(`{"title" : "blah blah blah"}`)) + + if err == nil { + t.Errorf("Update for ID 1234 should have failed but didn't") + } +} + +func TestUpdateRecordAtinvalidID(t *testing.T) { + + setupDataFileForTest() + + _, err := updateById("notes", "ABCD", []byte(`{"title" : "blah blah blah"}`)) + + if err == nil { + t.Errorf("Update for ID ABCD should have failed but didn't") + } +}