Browse Source

Add functional first implementation with integration test

Andrew Swistak 7 years ago
parent
commit
5bf3c4ef6b
9 changed files with 447 additions and 5 deletions
  1. 23 0
      pokemon-parsing/gen7/parse.go
  2. 85 0
      pokemon-parsing/parse.go
  3. 12 0
      pokemon/pokemon.go
  4. 62 0
      web/parse.go
  5. 140 0
      web/parse_integration_test.go
  6. 28 0
      web/respond.go
  7. 43 0
      web/respond_test.go
  8. 6 5
      web/server.go
  9. 48 0
      web/server_test.go

+ 23 - 0
pokemon-parsing/gen7/parse.go

@@ -0,0 +1,23 @@
+package gen7
+
+import (
+	"fmt"
+
+	p "github.com/ajswis/go-pkparse-server/pokemon"
+)
+
+// Parse accepts a raw pokemon (byte slice) and either returns a Pokemon struct
+// with all applicable fields populated or an error depicting the reason for
+// failure.
+func Parse(rawPokemon p.RawPokemon) (*p.Pokemon, error) {
+	if l := len(rawPokemon); l < 232 {
+		return nil, fmt.Errorf("Invalid length for generation 7 pokemon: %d bytes", l)
+	}
+
+	var pkmn p.Pokemon
+	pkmn.RawNickname = rawPokemon[0x40:0x58]
+	pkmn.Nickname = string(pkmn.RawNickname)
+	pkmn.RawPokemon = &rawPokemon
+
+	return &pkmn, nil
+}

+ 85 - 0
pokemon-parsing/parse.go

@@ -0,0 +1,85 @@
+package pokemonparsing
+
+import (
+	"sync"
+
+	p "github.com/ajswis/go-pkparse-server/pokemon"
+	"github.com/ajswis/go-pkparse-server/pokemon-parsing/gen7"
+)
+
+// NOTE: For unit testing simplicity, perhaps defining a parsing interface and
+// wrapper type for all parsers would make isolating tests easier.
+
+// Parse returns either a parsed Pokemon with all applicable struct fields
+// populated, or an error detailing what was wrong.
+func Parse(rawPokemon p.RawPokemon) (*p.Pokemon, error) {
+	// TODO: Conditionally swap to different generation parsers depending on given
+	// byte slice.
+	return gen7.Parse(rawPokemon)
+}
+
+// ParseAll returns either a slice of parsed Pokemon with all applicable struct
+// fields populated, or an error detailing what was wrong with any attempt.
+//
+// Additionally, ParseAll will parallelize large data sets. This concurrency
+// might be a preemptive optimization at the moment, but once parsers are
+// fully realized, benchmarks can determine the usefulness of parallelization.
+func ParseAll(rawPokemon []p.RawPokemon) ([]*p.Pokemon, error) {
+	if len(rawPokemon) >= 10 {
+		return concurrentParseAll(rawPokemon)
+	}
+	return serialParseAll(rawPokemon)
+}
+
+func serialParseAll(rawPokemon []p.RawPokemon) ([]*p.Pokemon, error) {
+	var pkmn = []*p.Pokemon{}
+
+	for _, p := range rawPokemon {
+		parsed, err := Parse(p)
+		if err != nil {
+			return nil, err
+		}
+		pkmn = append(pkmn, parsed)
+	}
+
+	return pkmn, nil
+}
+
+type parseResult struct {
+	pkmn *p.Pokemon
+	err  error
+}
+
+// concurrentParseAll fans out the data set to Parse in parallel, but does not
+// end early on a failure or cancelled request. Providing done channels can
+// resolve this, but it would probably be worth waiting until handling of
+// `context.Context`s is applied.
+func concurrentParseAll(rawPokemon []p.RawPokemon) ([]*p.Pokemon, error) {
+	var wg sync.WaitGroup
+	pkmnc := make(chan *parseResult)
+
+	for _, pkmn := range rawPokemon {
+		wg.Add(1)
+		go func(unparsed p.RawPokemon) {
+			defer wg.Done()
+
+			res, err := Parse(unparsed)
+			pkmnc <- &parseResult{pkmn: res, err: err}
+		}(pkmn)
+	}
+
+	go func() {
+		wg.Wait()
+		close(pkmnc)
+	}()
+
+	var parsedPokemon []*p.Pokemon
+	for res := range pkmnc {
+		if res.err != nil {
+			return nil, res.err
+		}
+		parsedPokemon = append(parsedPokemon, res.pkmn)
+	}
+
+	return parsedPokemon, nil
+}

+ 12 - 0
pokemon/pokemon.go

@@ -0,0 +1,12 @@
+package pokemon
+
+// RawPokemon is a re-typed byte slice.
+type RawPokemon []byte
+
+// Pokemon is a struct the has fields for all relevant pokemon data from a
+// RawPokemon.
+type Pokemon struct {
+	RawNickname []byte      `json:"raw_nickname"`
+	Nickname    string      `json:"nickname"`
+	RawPokemon  *RawPokemon `json:"raw_pokemon"`
+}

+ 62 - 0
web/parse.go

@@ -0,0 +1,62 @@
+package web
+
+import (
+	"bytes"
+	"io"
+	"mime/multipart"
+	"net/http"
+
+	p "github.com/ajswis/go-pkparse-server/pokemon"
+	"github.com/ajswis/go-pkparse-server/pokemon-parsing"
+	"github.com/gin-gonic/gin"
+)
+
+/*
+  curl -X POST http://localhost:8080/parse \
+	  -F "pkmn=@/path/to/test/file1" \
+	  -F "pkmn=@/path/to/test/file2" \
+	  -H "Content-Type: multipart/form-data"
+*/
+
+// parse grabs multipart file uploads under the key `pkmn`, aggregates them, and
+// delegates work to the parser.
+// NOTE: This can probably be improved by 1) parallelizing upload reads, or 2)
+// not accepting file uploads, preferring to service binary data as encoded
+// strings
+func (s *Server) parse(c *gin.Context) {
+	form, err := c.MultipartForm()
+	if err != nil {
+		render(c, http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	var rawPokemon []p.RawPokemon
+	var files []*multipart.FileHeader = form.File["pkmn"]
+	for _, file := range files {
+		var err error
+		var src multipart.File
+
+		src, err = file.Open()
+		if err != nil {
+			render(c, http.StatusInternalServerError, gin.H{"error": err.Error()})
+			return
+		}
+		defer src.Close()
+
+		buf := bytes.NewBuffer(nil)
+		if _, err = io.Copy(buf, src); err != nil {
+			render(c, http.StatusInternalServerError, gin.H{"error": err.Error()})
+			return
+		}
+
+		rawPokemon = append(rawPokemon, buf.Bytes())
+	}
+
+	pkmn, err := pokemonparsing.ParseAll(rawPokemon)
+	if err != nil {
+		render(c, http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
+		return
+	}
+
+	render(c, http.StatusOK, gin.H{"pkmn": pkmn})
+}

+ 140 - 0
web/parse_integration_test.go

@@ -0,0 +1,140 @@
+package web
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+)
+
+func init() {
+	gin.SetMode(gin.TestMode)
+}
+
+func Test_parse(t *testing.T) {
+	tests := []struct {
+		name           string
+		body           [][]byte
+		expectedStatus int
+		expectedReply  string
+	}{
+		{name: "No files uploaded",
+			body:           [][]byte{},
+			expectedStatus: 200,
+			expectedReply:  `{"pkmn":[]}`,
+		},
+		{name: "1 file uploaded and succeeds",
+			body:           [][]byte{{0x7b, 0x03, 0x17, 0x81, 0x00, 0x00, 0xdd, 0x01, 0x14, 0x00, 0x00, 0x00, 0x6d, 0xaa, 0x32, 0x44, 0x40, 0x1f, 0x00, 0x00, 0x2f, 0x04, 0x00, 0x00, 0x43, 0x56, 0x88, 0x03, 0x12, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x52, 0x00, 0x61, 0x00, 0x74, 0x00, 0x69, 0x00, 0x63, 0x00, 0x61, 0x00, 0x74, 0x00, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x00, 0x75, 0x01, 0x53, 0x01, 0x9e, 0x00, 0x0a, 0x0f, 0x14, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xfc, 0xff, 0x17, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x0b, 0x12, 0x00, 0x00, 0x00, 0xca, 0x00, 0x04, 0x14, 0x00, 0x21, 0x01, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00}},
+			expectedStatus: 200,
+			expectedReply:  "{\"pkmn\":[{\"raw_nickname\":\"UgBhAHQAaQBjAGEAdABlAAAAAAAAAAAA\",\"nickname\":\"R\\u0000a\\u0000t\\u0000i\\u0000c\\u0000a\\u0000t\\u0000e\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\",\"raw_pokemon\":\"ewMXgQAA3QEUAAAAbaoyREAfAAAvBAAAQ1aIAxISAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFIAYQB0AGkAYwBhAHQAZQAAAAAAAAAAAAAApAB1AVMBngAKDxQPAAAAAAAAAAAAAAAAAAA8/P8XUABQAG8AcgBnAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAARgAAAAAAAAAAAAAAAABQAFAAbwByAGcAAAAAAAAAAAAAAAAAAAAAAEYAAAAAAAAAAAARCxIAAADKAAQUACEBAgACAAAAAA==\"}]}",
+		},
+		{name: "1 file uploaded and fails",
+			body:           [][]byte{{0x7b}},
+			expectedStatus: 422,
+			expectedReply:  "{\"error\":\"Invalid length for generation 7 pokemon: 1 bytes\"}",
+		},
+		{name: "Multiple files uploaded and succeed",
+			body: [][]byte{
+				{0x7b, 0x03, 0x17, 0x81, 0x00, 0x00, 0xdd, 0x01, 0x14, 0x00, 0x00, 0x00, 0x6d, 0xaa, 0x32, 0x44, 0x40, 0x1f, 0x00, 0x00, 0x2f, 0x04, 0x00, 0x00, 0x43, 0x56, 0x88, 0x03, 0x12, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x52, 0x00, 0x61, 0x00, 0x74, 0x00, 0x69, 0x00, 0x63, 0x00, 0x61, 0x00, 0x74, 0x00, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x00, 0x75, 0x01, 0x53, 0x01, 0x9e, 0x00, 0x0a, 0x0f, 0x14, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xfc, 0xff, 0x17, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x0b, 0x12, 0x00, 0x00, 0x00, 0xca, 0x00, 0x04, 0x14, 0x00, 0x21, 0x01, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00},
+				{0x7b, 0x03, 0x17, 0x81, 0x00, 0x00, 0xdd, 0x01, 0x14, 0x00, 0x00, 0x00, 0x6d, 0xaa, 0x32, 0x44, 0x40, 0x1f, 0x00, 0x00, 0x2f, 0x04, 0x00, 0x00, 0x43, 0x56, 0x88, 0x03, 0x12, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x52, 0x00, 0x61, 0x00, 0x74, 0x00, 0x69, 0x00, 0x63, 0x00, 0x61, 0x00, 0x74, 0x00, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x00, 0x75, 0x01, 0x53, 0x01, 0x9e, 0x00, 0x0a, 0x0f, 0x14, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xfc, 0xff, 0x17, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x0b, 0x12, 0x00, 0x00, 0x00, 0xca, 0x00, 0x04, 0x14, 0x00, 0x21, 0x01, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00},
+			},
+			expectedStatus: 200,
+			expectedReply:  "{\"pkmn\":[{\"raw_nickname\":\"UgBhAHQAaQBjAGEAdABlAAAAAAAAAAAA\",\"nickname\":\"R\\u0000a\\u0000t\\u0000i\\u0000c\\u0000a\\u0000t\\u0000e\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\",\"raw_pokemon\":\"ewMXgQAA3QEUAAAAbaoyREAfAAAvBAAAQ1aIAxISAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFIAYQB0AGkAYwBhAHQAZQAAAAAAAAAAAAAApAB1AVMBngAKDxQPAAAAAAAAAAAAAAAAAAA8/P8XUABQAG8AcgBnAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAARgAAAAAAAAAAAAAAAABQAFAAbwByAGcAAAAAAAAAAAAAAAAAAAAAAEYAAAAAAAAAAAARCxIAAADKAAQUACEBAgACAAAAAA==\"},{\"raw_nickname\":\"UgBhAHQAaQBjAGEAdABmAAAAAAAAAAAA\",\"nickname\":\"R\\u0000a\\u0000t\\u0000i\\u0000c\\u0000a\\u0000t\\u0000f\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\\u0000\",\"raw_pokemon\":\"ewMXgQAA3QEUAAAAbaoyREAfAAAvBAAAQ1aIAxISAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFIAYQB0AGkAYwBhAHQAZgAAAAAAAAAAAAAApAB1AVMBngAKDxQPAAAAAAAAAAAAAAAAAAA8/P8XUABQAG8AcgBnAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAARgAAAAAAAAAAAAAAAABQAFAAbwByAGcAAAAAAAAAAAAAAAAAAAAAAEYAAAAAAAAAAAARCxIAAADKAAQUACEBAgACAAAAAA==\"}]}",
+		},
+		{name: "Multiple files uploaded and fail",
+			body: [][]byte{
+				{0x7b, 0x03, 0x17, 0x81, 0x00, 0x00, 0xdd, 0x01, 0x14, 0x00, 0x00, 0x00, 0x6d, 0xaa, 0x32, 0x44, 0x40, 0x1f, 0x00, 0x00, 0x2f, 0x04, 0x00, 0x00, 0x43, 0x56, 0x88, 0x03, 0x12, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x52, 0x00, 0x61, 0x00, 0x74, 0x00, 0x69, 0x00, 0x63, 0x00, 0x61, 0x00, 0x74, 0x00, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xa4, 0x00, 0x75, 0x01, 0x53, 0x01, 0x9e, 0x00, 0x0a, 0x0f, 0x14, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0xfc, 0xff, 0x17, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x50, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x67, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x0b, 0x12, 0x00, 0x00, 0x00, 0xca, 0x00, 0x04, 0x14, 0x00, 0x21, 0x01, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00},
+				{0x7b, 0x03},
+			},
+			expectedStatus: 422,
+			expectedReply:  "{\"error\":\"Invalid length for generation 7 pokemon: 2 bytes\"}",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := &Server{}
+
+			rec := httptest.NewRecorder()
+			ctx, _ := gin.CreateTestContext(rec)
+
+			var b bytes.Buffer
+			var err error
+			var fw io.Writer
+
+			w := multipart.NewWriter(&b)
+
+			for i, postBody := range tt.body {
+				if fw, err = w.CreateFormFile("pkmn", fmt.Sprintf("test%d.pk7", i+1)); err != nil {
+					assert.Fail(t, "Failed to create form file: %s", err)
+					return
+				}
+				body := bytes.NewBuffer(postBody)
+				if _, err = io.Copy(fw, body); err != nil {
+					assert.Fail(t, "Failed to copy buffer: %s", err)
+					return
+				}
+			}
+
+			w.Close()
+
+			var req *http.Request
+			if req, err = http.NewRequest(http.MethodPost, "/parse", &b); err != nil {
+				assert.Fail(t, "Failed to create request: %s", err)
+				return
+			}
+			req.Header.Set("Content-Type", w.FormDataContentType())
+			ctx.Request = req
+
+			s.parse(ctx)
+			assert.Equal(t, tt.expectedReply, rec.Body.String())
+			assert.Equal(t, tt.expectedStatus, ctx.Writer.Status())
+		})
+	}
+}
+
+//    //prepare the reader instances to encode
+//    values := map[string]io.Reader{
+//        "file":  mustOpen("main.go"), // lets assume its this file
+//        "other": strings.NewReader("hello world!"),
+//    }
+//    err := Upload(client, remoteURL, values)
+//    if err != nil {
+//        panic(err)
+//    }
+//}
+
+//func Upload(client *http.Client, url string, values map[string]io.Reader) (err error) {
+//    // Prepare a form that you will submit to that URL.
+//    var b bytes.Buffer
+//    w := multipart.NewWriter(&b)
+//    for key, r := range values {
+//        var fw io.Writer
+//        if x, ok := r.(io.Closer); ok {
+//            defer x.Close()
+//        }
+//        // Add an image file
+//        if x, ok := r.(*os.File); ok {
+//            if fw, err = w.CreateFormFile(key, x.Name()); err != nil {
+//                return
+//            }
+//        } else {
+//            // Add other fields
+//            if fw, err = w.CreateFormField(key); err != nil {
+//                return
+//            }
+//        }
+//        if _, err = io.Copy(fw, r); err != nil {
+//            return err
+//        }
+//    }
+//    // Don't forget to close the multipart writer.
+//    // If you don't close it, your request will be missing the terminating boundary.
+//    w.Close()
+//    // Now that you have a form, you can submit it to your handler.
+//    req, err := http.NewRequest("POST", url, &b)

+ 28 - 0
web/respond.go

@@ -0,0 +1,28 @@
+package web
+
+import (
+	"log"
+
+	"github.com/gin-gonic/gin"
+	r "github.com/gin-gonic/gin/render"
+)
+
+// render is a wrapper function that decides how to render a response, renders
+// the response, and can be extended to include additional tasks when rendering
+// for debug purposes, such as logging failures and contextually including extra
+// information.
+func render(c *gin.Context, status int, data interface{}) {
+	var renderer r.Render
+
+	if gin.IsDebugging() {
+		renderer = r.IndentedJSON{Data: data}
+	} else {
+		renderer = r.JSON{Data: data}
+	}
+
+	if status > 400 {
+		log.Printf("Failed to process request\tstatus: %d\tdata: %+v", status, data)
+	}
+
+	c.Render(status, renderer)
+}

+ 43 - 0
web/respond_test.go

@@ -0,0 +1,43 @@
+package web
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_render(t *testing.T) {
+	c, _ := gin.CreateTestContext(httptest.NewRecorder())
+
+	data := gin.H{"foo": "bar"}
+	returnStatus := 500
+	assert.Equal(t, 200, c.Writer.Status())
+	render(c, returnStatus, data)
+	assert.Equal(t, 500, c.Writer.Status())
+}
+
+func Test_render_rendering(t *testing.T) {
+	mode := gin.Mode()
+	defer gin.SetMode(mode)
+
+	// gin.IsDebugging() == true
+	gin.SetMode(gin.DebugMode)
+	rec := httptest.NewRecorder()
+	c, _ := gin.CreateTestContext(rec)
+
+	data := gin.H{"foo": "bar"}
+	render(c, 200, data)
+	expectedResp := "{\n    \"foo\": \"bar\"\n}"
+	assert.Equal(t, expectedResp, rec.Body.String())
+
+	// gin.IsDebugging() == false
+	gin.SetMode(gin.ReleaseMode)
+	rec = httptest.NewRecorder()
+	c, _ = gin.CreateTestContext(rec)
+
+	render(c, 200, data)
+	expectedResp = `{"foo":"bar"}`
+	assert.Equal(t, expectedResp, rec.Body.String())
+}

+ 6 - 5
web/server.go

@@ -15,19 +15,20 @@ type Server struct {
 // NewServer returns a configured instance of the web server.
 func NewServer() *Server {
 	router := gin.Default()
+	s := &Server{router: router}
+
 	router.GET("/ping", ping)
+	router.POST("/parse", s.parse)
 
-	return &Server{
-		router: router,
-	}
+	return s
 }
 
-// ping is a dummy function to see if things are running well enough.
+// ping is a dummy function to check if the server is running and responding.
 func ping(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{"message": "pong"})
 }
 
-// Run runs our server!
+// Run runs the server!
 func (s *Server) Run() {
 	s.router.Run()
 }

+ 48 - 0
web/server_test.go

@@ -0,0 +1,48 @@
+package web
+
+import (
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewServer_routes(t *testing.T) {
+	s := NewServer()
+	routes := s.router.Routes()
+	checkedRoutes := make(map[gin.RouteInfo]bool)
+
+	tests := []struct {
+		path    string
+		method  string
+		handler string
+	}{
+		{path: "/ping", method: "GET", handler: "github.com/ajswis/go-pkparse-server/web.ping"},
+		{path: "/parse", method: "POST", handler: "github.com/ajswis/go-pkparse-server/web.(*Server).(github.com/ajswis/go-pkparse-server/web.parse)-fm"},
+	}
+	for _, tt := range tests {
+		var ran bool
+		for _, route := range routes {
+			if route.Path == tt.path {
+				t.Run(tt.path, func(t *testing.T) {
+					assert.Equal(t, tt.path, route.Path)
+					assert.Equal(t, tt.method, route.Method)
+					assert.Equal(t, tt.handler, route.Handler)
+				})
+
+				ran = true
+				checkedRoutes[route] = true
+				break
+			}
+		}
+		if !ran {
+			assert.Failf(t, "Unknown route", "Don't know about %s", tt.path)
+		}
+	}
+
+	for _, route := range routes {
+		if !checkedRoutes[route] {
+			assert.Failf(t, "Unchecked route", "Didn't check %s", route.Path)
+		}
+	}
+}