Skip to content

Commit

Permalink
version 2
Browse files Browse the repository at this point in the history
  • Loading branch information
gconnell committed Mar 14, 2023
1 parent 015d209 commit e2f11d4
Show file tree
Hide file tree
Showing 7 changed files with 720 additions and 0 deletions.
3 changes: 3 additions & 0 deletions v2/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/g2nconnell/toolkit/v2

go 1.20
21 changes: 21 additions & 0 deletions v2/license.md
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.
19 changes: 19 additions & 0 deletions v2/readme.md
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`
Binary file added v2/testdata/img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added v2/testdata/pic.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
319 changes: 319 additions & 0 deletions v2/tools.go
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
}
Loading

0 comments on commit e2f11d4

Please sign in to comment.