diff --git a/v2/README.md b/v2/README.md new file mode 100644 index 0000000..dc8a67d --- /dev/null +++ b/v2/README.md @@ -0,0 +1,240 @@ +# Toolkit Package + +[![CI Build](https://github.com/rozdolsky33/toolkit/actions/workflows/build.yml/badge.svg)](https://github.com/rozdolsky33/toolkit/actions/workflows/build.yml) +[![Coverage Status](https://coveralls.io/repos/github/rozdolsky33/toolkit/badge.svg?branch=main)](https://coveralls.io/github/rozdolsky33/toolkit?branch=main) + +This toolkit package provides a set of utilities designed for the Go programming language to handle common tasks like generating random strings and handling file uploads in web applications. + +## Features + +The included tools are: + +- [X] Read JSON +- [X] Write JSON +- [X] Produce a JSON encoded error response +- [X] Upload files via HTTP requests with optional renaming and file type validation. +- [X] Download a static file +- [X] Get a random string of length n +- [X] Post JSON to a remote service +- [X] Create a directory, including all parent directories, if it does not already exist +- [X] Create a URL-safe slug from a string + +## Installation + +To install the package, use the following command: + +```sh +go get github.com/rozdolsky33/toolkit +``` + +## Usage + +### Importing the Package + +Before using the package, import it in your Go project: + +```go +import "github.com/rozdolsky33/toolkit" +``` + +### Initializing the Tools + +Create an instance of the `Tools` type to access its methods: + +```go +tools := toolkit.Tools{ + MaxFileSize: 1024 * 1024 * 1024, // 1 GB + AllowedFileTypes: []string{"image/jpeg", "image/png"}, +} +``` + +### Generating Random Strings + +Use the `RandomString` method to generate a random string of specified length: + +```go +randomStr := tools.RandomString(16) +fmt.Println("Random String:", randomStr) +``` + +### Uploading Files + +Use the `UploadFiles` method to handle file uploads from HTTP requests. You can also specify whether to rename the uploaded files or not: + +```go +func uploadHandler(w http.ResponseWriter, r *http.Request) { + uploadDir := "./uploads" + + uploadedFiles, err := tools.UploadFiles(r, uploadDir, true) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + for _, file := range uploadedFiles { + fmt.Fprintf(w, "Uploaded File: %s (original: %s), Size: %d bytes\n", + file.NewFileName, file.OriginalFileName, file.FileSize) + } +} +``` + +In your main function, set up the HTTP server to use this handler: + +```go +http.HandleFunc("/upload", uploadHandler) +http.ListenAndServe(":8080, nil) +``` + +## Structs + +### Tools + +The `Tools` struct is used to instantiate the toolkit. This struct holds configuration for file uploads and JSON operations. + +- `MaxFileSize int`: Maximum allowed file size for uploads (in bytes). +- `AllowedFileTypes []string`: List of allowed file MIME types for validation. +- `MaxJSONSize int`: Maximum allowed JSON size in bytes. +- `AllowUnknownFields bool`: Flag to allow unknown JSON fields. + +### UploadedFile + +The `UploadedFile` struct holds information about the uploaded files. + +- `NewFileName string`: The name of the file saved on the server. +- `OriginalFileName string`: The original name of the uploaded file. +- `FileSize int64`: The size of the uploaded file in bytes. + +### JSONResponse + +The `JSONResponse` struct is used to format JSON responses. + +- `Error bool`: Indicates if the response is an error. +- `Message string`: The message to be included in the response. +- `Data interface{}`: Optional data payload. + +## Methods + +### `RandomString` + +Generates a random string of specified length `n`. + +```go +func (t *Tools) RandomString(n int) string +``` + +### `UploadFiles` + +Handles file uploads from HTTP requests, validates file type and optionally renames files. + +```go +func (t *Tools) UploadFiles(r *http.Request, uploadDir string, rename ...bool) ([]*UploadedFile, error) +``` + +- `r *http.Request`: The HTTP request object. +- `uploadDir string`: Directory path where files will be uploaded. +- `rename ...bool`: Optional boolean to specify whether to rename uploaded files. + +### `CreateDirIfNotExist` + +Creates a directory if it does not exist. + +```go +func (t *Tools) CreateDirIfNotExist(dir string) error +``` + +- `dir string`: The directory path. + +### `Slugify` + +Transforms an input string into a URL-friendly slug. + +```go +func (t *Tools) Slugify(s string) (string, error) +``` + +- `s string`: The input string to be slugified. + +### `DownloadStaticFile` + +Downloads a file and tries to force the browser to avoid displaying it in the browser window by setting content disposition. + +```go +func (t *Tools) DownloadStaticFile(w http.ResponseWriter, r *http.Request, p, file, displayName string) +``` + +- `w http.ResponseWriter`: The HTTP response writer. +- `r *http.Request`: The HTTP request. +- `p string`: The file path. +- `file string`: The filename. +- `displayName string`: The display name. + +### `ReadJSON` + +Reads and decodes JSON from a request body. + +```go +func (t *Tools) ReadJSON(w http.ResponseWriter, r *http.Request, data interface{}) error +``` + +- `w http.ResponseWriter`: The HTTP response writer. +- `r *http.Request`: The HTTP request. +- `data interface{}`: The target data structure. + +### `WriteJSON` + +Encodes data as JSON and writes it to the response. + +```go +func (t *Tools) WriteJSON(w http.ResponseWriter, status int, data interface{}, headers ...http.Header) error +``` + +- `w http.ResponseWriter`: The HTTP response writer. +- `status int`: The HTTP status code. +- `data interface{}`: The payload to be encoded as JSON. +- `headers ...http.Header`: Optional headers. + +### `ErrorJSON` + +Generates and sends a JSON error response. + +```go +func (t *Tools) ErrorJSON(w http.ResponseWriter, err error, status ...int) error +``` + +- `w http.ResponseWriter`: The HTTP response writer. +- `err error`: The error to be included in the response. +- `status ...int`: Optional HTTP status code. + +### `PushJSONToRemote` + +Sends the given data as a JSON payload to a specified URI via HTTP POST using an optional custom HTTP client. + +```go +func (t *Tools) PushJSONToRemote(uri string, data interface{}, client ...*http.Client) (*http.Response, int, error) +``` + +- `uri string`: The target URI. +- `data interface{}`: The data to be sent as JSON. +- `client ...*http.Client`: Optional custom HTTP client. + +# MIT License + +### Copyright (c) 2024 Volodymyr Rozdolsky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/v2/covprofile b/v2/covprofile new file mode 100644 index 0000000..79b28a0 --- /dev/null +++ b/v2/covprofile @@ -0,0 +1 @@ +mode: atomic diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 0000000..ef026fe --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,3 @@ +module github.com/rozdolsky33/toolkit/v2 + +go 1.23.1 diff --git a/v2/testdata/img.png b/v2/testdata/img.png new file mode 100644 index 0000000..1d69b1e Binary files /dev/null and b/v2/testdata/img.png differ diff --git a/v2/testdata/pic.jpg b/v2/testdata/pic.jpg new file mode 100644 index 0000000..3b14ce7 Binary files /dev/null and b/v2/testdata/pic.jpg differ diff --git a/v2/tools.go b/v2/tools.go new file mode 100644 index 0000000..7f4a86e --- /dev/null +++ b/v2/tools.go @@ -0,0 +1,312 @@ +package toolkit + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +// randomStringSource defines the character set used for generating random strings. +const randomStringSource = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVXWYZ0123456789_+" + +// Tools is the type used to instantiate this module. Any variable of this type will have access to all the methods with the receiver *Tools. +type Tools struct { + MaxFileSize int + AllowedFileTypes []string + MaxJSONSize int + AllowUnknownFields bool +} + +// RandomString returns a string of random characters of length n, using randomStringSource as the source for the string +func (t *Tools) RandomString(n int) string { + s, r := make([]rune, n), []rune(randomStringSource) + for i := range s { + p, _ := rand.Prime(rand.Reader, len(r)) + x, y := p.Uint64(), uint64(len(r)) + s[i] = r[x%y] + } + return string(s) +} + +// UploadedFile is a struct used to save information about an uploaded file +type UploadedFile struct { + NewFileName string + OriginalFileName string + FileSize int64 +} + +// UploadOneFile uploads a single file from the provided HTTP request, storing it in the specified directory. +// If the optional rename argument is true or not provided, the file will be renamed. +func (t *Tools) UploadOneFile(r *http.Request, uploadDir string, rename ...bool) (*UploadedFile, error) { + renameFile := true + if len(rename) > 0 { + renameFile = rename[0] + } + + files, err := t.UploadFiles(r, uploadDir, renameFile) + + if err != nil { + return nil, err + } + + return files[0], nil +} + +// UploadFiles uploads multiple files from the provided HTTP request, storing them in the specified directory. +// If the optional rename argument is true or not provided, the files will be renamed. +func (t *Tools) UploadFiles(r *http.Request, uploadDir string, rename ...bool) ([]*UploadedFile, error) { + renameFile := true + if len(rename) > 0 { + renameFile = rename[0] + } + var uploadedFiles []*UploadedFile + if t.MaxFileSize == 0 { + t.MaxFileSize = 1024 * 1024 * 1024 // 1Gb + } + + err := t.CreateDirIfNotExist(uploadDir) + if err != nil { + return nil, err + } + + err = r.ParseMultipartForm(int64(t.MaxFileSize)) + if err != nil { + return nil, errors.New("error parsing multipart form: " + err.Error()) + } + for _, fHeaders := range r.MultipartForm.File { + for _, hdr := range fHeaders { + uploadedFiles, err = func(uploadedFiles []*UploadedFile) ([]*UploadedFile, error) { + var uploadedFile UploadedFile + infile, err := hdr.Open() + if err != nil { + return nil, err + } + defer infile.Close() + + buff := make([]byte, 512) + _, err = infile.Read(buff) + + if err != nil { + return nil, err + } + + //TODO: Check to see if the file type is permitted + allowed := false + fileType := http.DetectContentType(buff) + + if len(t.AllowedFileTypes) > 0 { + for _, x := range t.AllowedFileTypes { + if strings.EqualFold(fileType, x) { + allowed = true + } + } + } else { + allowed = true + } + if !allowed { + return nil, errors.New("file type not allowed: " + fileType) + } + _, err = infile.Seek(0, 0) + if err != nil { + return nil, err + } + if renameFile { + uploadedFile.NewFileName = fmt.Sprintf("%s%s", t.RandomString(25), filepath.Ext(hdr.Filename)) + } else { + uploadedFile.NewFileName = hdr.Filename + } + + uploadedFile.OriginalFileName = hdr.Filename + + var outFile *os.File + defer outFile.Close() + if outFile, err = os.Create(filepath.Join(uploadDir, uploadedFile.NewFileName)); err != nil { + return nil, err + } else { + fileSize, err := io.Copy(outFile, infile) + if err != nil { + return nil, err + } + uploadedFile.FileSize = fileSize + } + uploadedFiles = append(uploadedFiles, &uploadedFile) + return uploadedFiles, nil + }(uploadedFiles) + if err != nil { + return uploadedFiles, err + } + } + } + return uploadedFiles, nil +} + +// CreateDirIfNotExist creates a directory with the specified name if it does not already exist. +func (t *Tools) CreateDirIfNotExist(dir string) error { + const mode = 0755 + if _, err := os.Stat(dir); os.IsNotExist(err) { + err = os.MkdirAll(dir, mode) + if err != nil { + return err + } + } + return nil +} + +// Slugify transforms an input string into a URL-friendly slug by replacing non-alphanumeric characters with hyphens. +func (t *Tools) Slugify(s string) (string, error) { + if s == "" { + return "", errors.New("empty string not permitted") + } + var regEx = regexp.MustCompile(`[^a-z\d]+`) + + slug := strings.Trim(regEx.ReplaceAllString(strings.ToLower(s), "-"), "-") + + if len(slug) == 0 { + return "", errors.New("after removing characters, slug is zero length") + } + + return slug, nil +} + +// DownloadStaticFile downloads a file, and tries to force the browser to avoid displaying it +// in the browser window by setting content disposition. It also allows specification of the display name +func (t *Tools) DownloadStaticFile(w http.ResponseWriter, r *http.Request, pathName, displayName string) { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", displayName)) + http.ServeFile(w, r, pathName) +} + +type JSONResponse struct { + Error bool `json:"error"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// ReadJSON tries to read the body of a request and coverts from json into a go dta variable +func (t *Tools) ReadJSON(w http.ResponseWriter, r *http.Request, data interface{}) error { + maxBytes := 1024 * 1024 // 1MB + + if t.MaxJSONSize != 0 { + maxBytes = t.MaxJSONSize + } + + r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) + + dec := json.NewDecoder(r.Body) + + if !t.AllowUnknownFields { + dec.DisallowUnknownFields() + } + + err := dec.Decode(data) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var invalidUnmarshalError *json.InvalidUnmarshalError + + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("body contains badly-formed JSON") + case errors.As(err, &unmarshalTypeError): + if unmarshalTypeError.Field != "" { + return fmt.Errorf("body contains icnorrect JSON type for field %q", unmarshalTypeError.Field) + } + return fmt.Errorf("body contains an invalid JSON (at character %d)", unmarshalTypeError.Offset) + case errors.Is(err, io.EOF): + return errors.New("body must not be empty") + case strings.HasPrefix(err.Error(), "json: unknown field"): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field") + return fmt.Errorf("body contains unknown key %s", fieldName) + case err.Error() == "http: request body too large": + return fmt.Errorf("body must not be larger than %d bytes", maxBytes) + case errors.As(err, &invalidUnmarshalError): + return fmt.Errorf("error unmarshalling JSON: %s", err.Error()) + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + + if err != io.EOF { + return errors.New("body must contain only one JSON value") + } + return nil +} + +// WriteJSON takes a response status code and arbitrary data and writes json to the client +func (t *Tools) WriteJSON(w http.ResponseWriter, status int, data interface{}, headers ...http.Header) error { + out, err := json.Marshal(data) + if err != nil { + return err + } + + if len(headers) > 0 { + for key, val := range headers[0] { + w.Header()[key] = val + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, err = w.Write(out) + if err != nil { + return err + } + return nil +} + +// ErrorJSON takes an error, and optionally a status code, and generates / sends a JSON error message +func (t *Tools) ErrorJSON(w http.ResponseWriter, err error, status ...int) error { + statusCode := http.StatusBadRequest + + if len(status) > 0 { + statusCode = status[0] + } + + var payload JSONResponse + payload.Error = true + payload.Message = err.Error() + + return t.WriteJSON(w, statusCode, payload) +} + +// PushJSONToRemote sends the given data as a JSON payload to the specified URI via HTTP POST using an optional custom HTTP client. +func (t *Tools) PushJSONToRemote(uri string, data interface{}, client ...*http.Client) (*http.Response, int, error) { + // create json + jsonData, err := json.Marshal(data) + if err != nil { + return nil, 0, err + } + // check for custom http client + httpClient := &http.Client{} + if len(client) > 0 { + httpClient = client[0] + } + + //build the request and set the header + request, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, 0, err + } + request.Header.Set("Content-Type", "application/json") + + //call the remote uri + response, err := httpClient.Do(request) + if err != nil { + return nil, 0, err + } + defer response.Body.Close() + + // send response back + return response, response.StatusCode, nil +} diff --git a/v2/tools_test.go b/v2/tools_test.go new file mode 100644 index 0000000..deba15a --- /dev/null +++ b/v2/tools_test.go @@ -0,0 +1,347 @@ +package toolkit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "image" + "image/png" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" +) + +type RoundTripFunc func(req *http.Request) *http.Response + +func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +func NewTestClient(fn RoundTripFunc) *http.Client { + return &http.Client{ + Transport: fn, + } +} + +func TestTools_PushJSONToRemote(t *testing.T) { + client := NewTestClient(func(req *http.Request) *http.Response { + // Test Request Parameters + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewBufferString("OK")), + Header: make(http.Header), + } + }) + var testTools Tools + var foo struct { + Bar string `json:"bar"` + } + foo.Bar = "bar" + + _, _, err := testTools.PushJSONToRemote("http://example.com/some/path", foo, client) + if err != nil { + t.Errorf("failed to call remote url: %s", err) + } +} + +// TestTools_RandomString verifies that the RandomString method returns a string of the correct length. +func TestTools_RandomString(t *testing.T) { + var testTools Tools + s := testTools.RandomString(10) + if len(s) != 10 { + t.Error("wrong length random string returned") + } +} + +// uploadTests is a slice of test cases for upload functionality including test name, allowed file types, renaming flag, and error expectation. +var uploadTests = []struct { + name string + allowedTypes []string + renameFile bool + errorExpected bool +}{ + {name: "allowed no rename", allowedTypes: []string{"image/jpeg", "image/png"}, renameFile: false, errorExpected: false}, + {name: "allowed rename", allowedTypes: []string{"image/jpeg", "image/png"}, renameFile: true, errorExpected: false}, + {name: "not allowed", allowedTypes: []string{"image/jpeg"}, renameFile: false, errorExpected: true}, +} + +// TestTools_UploadFiles tests the file upload functionality via multipart form-data with various scenarios and configurations. +func TestTools_UploadFiles(t *testing.T) { + for _, e := range uploadTests { + //set up a pipe to avoid buffering + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + wg := sync.WaitGroup{} + wg.Add(1) + + go func() { + defer writer.Close() + defer wg.Done() + // crete the form data field 'file' + part, err := writer.CreateFormFile("file", "./testdata/img.png") + if err != nil { + t.Error(err) + } + + f, err := os.Open("./testdata/img.png") + + if err != nil { + t.Error(err) + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + t.Error("error decoding image", err) + } + err = png.Encode(part, img) + if err != nil { + t.Error(err) + } + }() + // read from the pipe which receives data + request := httptest.NewRequest(http.MethodPost, "/upload", pr) + request.Header.Add("Content-Type", writer.FormDataContentType()) + + var testTools Tools + testTools.AllowedFileTypes = e.allowedTypes + + uploadedFiles, err := testTools.UploadFiles(request, "./testdata/uploads/", e.renameFile) + if err != nil && !e.errorExpected { + t.Error(err) + } + if !e.errorExpected { + if _, err := os.Stat(fmt.Sprintf("./testdata/uploads/%s", uploadedFiles[0].NewFileName)); os.IsNotExist(err) { + t.Errorf("file %s not uploaded %s", e.name, err.Error()) + + } + // Clean up + _ = os.Remove(fmt.Sprintf("./testdata/uploads/%s", uploadedFiles[0].NewFileName)) + } + if !e.errorExpected && err != nil { + t.Errorf("%s: error expected but none received", e.name) + } + wg.Wait() + } +} + +// TestTools_UploadOneFile tests the UploadOneFile method to ensure a file can be uploaded, stored, and verified correctly. +func TestTools_UploadOneFile(t *testing.T) { + //set up a pipe to avoid buffering + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + go func() { + defer writer.Close() + + // crete the form data field 'file' + part, err := writer.CreateFormFile("file", "./testdata/img.png") + if err != nil { + t.Error(err) + } + + f, err := os.Open("./testdata/img.png") + + if err != nil { + t.Error(err) + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + t.Error("error decoding image", err) + } + err = png.Encode(part, img) + if err != nil { + t.Error(err) + } + }() + // read from the pipe which receives data + request := httptest.NewRequest(http.MethodPost, "/upload", pr) + request.Header.Add("Content-Type", writer.FormDataContentType()) + + var testTools Tools + + uploadedFiles, err := testTools.UploadOneFile(request, "./testdata/uploads/", true) + + if err != nil { + t.Error(err) + } + + if _, err := os.Stat(fmt.Sprintf("./testdata/uploads/%s", uploadedFiles.NewFileName)); os.IsNotExist(err) { + t.Errorf("file not uploaded %s", err.Error()) + } + // Clean up + _ = os.Remove(fmt.Sprintf("./testdata/uploads/%s", uploadedFiles.NewFileName)) +} + +func TestTools_CreateDirIfNotExist(t *testing.T) { + var testTools Tools + + err := testTools.CreateDirIfNotExist("./testdata/myDir") + + if err != nil { + t.Error(err) + } + + err = testTools.CreateDirIfNotExist("./testdata/myDir") + + if err != nil { + t.Error(err) + } + + _ = os.Remove(fmt.Sprintf("./testdata/myDir")) + +} + +var slugTests = []struct { + name string + s string + expected string + errorExpected bool +}{ + {name: "valid string", s: "Hello World", expected: "hello-world", errorExpected: false}, + {name: "empty string", s: "", expected: "", errorExpected: true}, + {name: "complex string", s: "Now is the time for all GOOD men! + fish & such &^123", expected: "now-is-the-time-for-all-good-men-fish-such-123", errorExpected: false}, + {name: " japanese string", s: "こんにちは世界", expected: "", errorExpected: true}, + {name: " japanese string and roman characters", s: "hello world こんにちは世界", expected: "hello-world", errorExpected: false}, +} + +func TestTools_Slugify(t *testing.T) { + var testTools Tools + + for _, test := range slugTests { + slug, err := testTools.Slugify(test.s) + if err != nil && !test.errorExpected { + t.Errorf("%s: error received but none expected: %s", test.name, err.Error()) + } + + if !test.errorExpected && slug != test.expected { + t.Errorf("%s: wrong slug retrned; expected %s but got %s", test.name, test.expected, slug) + } + } +} + +func TestTools_DownloadStaticFile(t *testing.T) { + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + + var testTools Tools + testTools.DownloadStaticFile(rr, req, "./testdata/pic.jpg", "puppy.jpg") + + res := rr.Result() + defer res.Body.Close() + + if res.Header["Content-Length"][0] != "98827" { + t.Error("wrong content length of", res.Header["Content-Length"][0]) + } + if res.Header["Content-Disposition"][0] != "attachment; filename=\"puppy.jpg\"" { + t.Error("wrong content disposition of", res.Header["Content-Disposition"][0]) + } + _, err := ioutil.ReadAll(res.Body) + + if err != nil { + t.Error(err) + } +} + +var jsonTests = []struct { + name string + json string + errorExpected bool + maxSize int + allowUnknown bool +}{ + {name: "valid json", json: `{"foo": "bar"}`, errorExpected: false, maxSize: 1024, allowUnknown: false}, + {name: "badly formatted json", json: `{"foo": }`, errorExpected: true, maxSize: 1024, allowUnknown: false}, + {name: "incorrect type", json: `{"foo": 1}`, errorExpected: true, maxSize: 1024, allowUnknown: false}, + {name: "two json files", json: `{"foo": "1""}{"alpha" : "beta"}`, errorExpected: true, maxSize: 1024, allowUnknown: false}, + {name: "empty json", json: ``, errorExpected: true, maxSize: 1024, allowUnknown: false}, + {name: "syntax error in json", json: `{"foo": 1""`, errorExpected: true, maxSize: 1024, allowUnknown: false}, + {name: "unknown field in json", json: `{"fod": "1"}`, errorExpected: true, maxSize: 1024, allowUnknown: false}, + {name: "allow unknown field in json", json: `{"fooo": "1"}`, errorExpected: false, maxSize: 1024, allowUnknown: true}, + {name: "missing field name in json", json: `{jack: "1"}`, errorExpected: true, maxSize: 1024, allowUnknown: true}, + {name: " file too large", json: `{"foo"": "bar"}`, errorExpected: true, maxSize: 4, allowUnknown: true}, + {name: "not jason ", json: `Hello World!`, errorExpected: true, maxSize: 1024, allowUnknown: true}, +} + +func TestTools_ReadJSON(t *testing.T) { + var testTools Tools + for _, test := range jsonTests { + // set the max file size + testTools.MaxJSONSize = test.maxSize + + // allow/disallow unknown fields + testTools.AllowUnknownFields = test.allowUnknown + + var decodedJSON struct { + Foo string `json:"foo"` + } + + // create a request with the body + req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte(test.json))) + if err != nil { + t.Log("Error:", err) + } + + // create a recorder + rr := httptest.NewRecorder() + + err = testTools.ReadJSON(rr, req, &decodedJSON) + + if test.errorExpected && err == nil { + t.Errorf("%s: error expected, but none reived: %s", test.name, err.Error()) + } + + if !test.errorExpected && err != nil { + t.Errorf("%s: error not expected but one received", test.name) + } + + req.Body.Close() + + } +} + +func TestTools_WriteJSON(t *testing.T) { + var testTools Tools + + rr := httptest.NewRecorder() + payload := JSONResponse{ + Error: false, + Message: "foo", + } + headers := make(http.Header) + headers.Add("FOO", "BAR") + + err := testTools.WriteJSON(rr, http.StatusOK, payload, headers) + if err != nil { + t.Errorf("failed to write JSON: %v", err) + } +} + +func TestTools_ErrorJSON(t *testing.T) { + var testTools Tools + + rr := httptest.NewRecorder() + err := testTools.ErrorJSON(rr, errors.New("some error"), http.StatusServiceUnavailable) + if err != nil { + t.Errorf("failed to write JSON: %v", err) + } + var payload JSONResponse + decoder := json.NewDecoder(rr.Body) + err = decoder.Decode(&payload) + if err != nil { + t.Errorf("failed to read JSON: %v", err) + } + if !payload.Error { + t.Errorf("error set to false in JSON, and it should be true") + } + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("error code set to false in JSON, and it should be http.StatusServiceUnavailable") + } +}