-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
gconnell
committed
Mar 14, 2023
1 parent
015d209
commit e2f11d4
Showing
7 changed files
with
720 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module github.com/g2nconnell/toolkit/v2 | ||
|
||
go 1.20 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
# MIT License | ||
|
||
### Copyright (c) 2023 Glenn Connell | ||
|
||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
# Toolkit | ||
|
||
A simple example of how to create a reusable Go module with commonly used tools. | ||
|
||
The included tools are: | ||
|
||
- [X] Read JSON | ||
- [X] Write JSON | ||
- [X] Produce a JSON encoded error response | ||
- [X] Upload a file to a specified directory | ||
- [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 | ||
|
||
`go get -u github.com/g2nconnell/toolkit` |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,319 @@ | ||
package toolkit | ||
|
||
import ( | ||
"bytes" | ||
"crypto/rand" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"os" | ||
"path/filepath" | ||
"regexp" | ||
"strings" | ||
) | ||
|
||
const randomStringSource = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+" | ||
|
||
// 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 of 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 | ||
} | ||
|
||
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 | ||
} | ||
|
||
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 | ||
} | ||
|
||
err := t.CreateDirIfNotExist(uploadDir) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
err = r.ParseMultipartForm(int64(t.MaxFileSize)) | ||
if err != nil { | ||
return nil, errors.New("the uploaded file is too big") | ||
} | ||
|
||
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("the uploaded file type is not permitted") | ||
} | ||
|
||
_, err = infile.Seek(0, 0) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
uploadedFile.OriginalFileName = hdr.Filename | ||
if renameFile { | ||
uploadedFile.NewFileName = fmt.Sprintf("%s%s", t.RandomString(25), filepath.Ext(hdr.Filename)) | ||
} else { | ||
uploadedFile.NewFileName = hdr.Filename | ||
} | ||
|
||
var outFile *os.File | ||
// using func causes outFile to referenced at runtime; otherwise outfile wasn't set yet. | ||
defer func() { 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, and all necessary parents, if it does not exist | ||
func (t *Tools) CreateDirIfNotExist(path string) error { | ||
const mode = 0755 | ||
if _, err := os.Stat(path); os.IsNotExist(err) { | ||
err := os.MkdirAll(path, mode) | ||
if err != nil { | ||
return err | ||
} | ||
} | ||
return nil | ||
} | ||
|
||
// Slugify is a very simple means of creating a slug from a string | ||
func (t *Tools) Slugify(s string) (string, error) { | ||
if s == "" { | ||
return "", errors.New("empty string not permitted") | ||
} | ||
|
||
var re = regexp.MustCompile(`[^a-z\d]+`) | ||
slug := strings.Trim(re.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 a body of a request and converts it from JSON into a Go data 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 incorrect JSON type for field %q", unmarshalTypeError.Field) | ||
} | ||
return fmt.Errorf("body contains incorrect JSON type (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 | ||
} | ||
} | ||
|
||
// more than one JSON file in body? | ||
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, value := range headers[0] { | ||
w.Header()[key] = value | ||
} | ||
} | ||
|
||
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 and 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 POSTS arbitrary data to some URL as JSON, and returns the response, status code and error if any | ||
// The final parameter, client, is optional. If none supplied, we use 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 request and set the header | ||
request, err := http.NewRequest("POST", 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 | ||
} |
Oops, something went wrong.