Uploading File in Gin With S3

1074 words - 6 min read

11/20/2022

photo credit


There is a lot of tutorial on how to upload a file in go, but not with the proper error handling, (that i know of, off course). but today im gonna show you how to upload a file in go using gin-gonic web framework and aws s3

in this tutorial, we dont create the initial boiler plate to handle the routes, i assume you guys know, and if you dont, please check another tutorial

Upload Using AWS-SDK-V2 permalink

At first, lets create an uploadService class with aws-s3 that gonna handle the upload file, we're using the upload manager to handle big file because upload manager makes file into chunks and each of the chunk get uploaded using goroutine

Create AWS-S3 Initialization permalink

you need to download and aws-sdk-go-v2 and put it in your project, you can change it by using plain context.TODO() if you dont wanna have a timeout

import (
  "github.com/aws/aws-sdk-go-v2/config"
  "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
  "github.com/aws/aws-sdk-go-v2/service/s3"
)
func NewS3() *uploadSvc {
  ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("YOURREGION"))
	if err != nil {
		log.Panicln(err) //should not happen, if happen, then, initialization failes
	}
	client := s3.NewFromConfig(cfg)
	return &attachment{
		uploader: manager.NewUploader(client, func(u *manager.Uploader) {
			u.PartSize = 10 << 20 //the file is chunked by 10MiB
		})
	}
}

type uploadSvc {
  uploader *manager.Uploader
}

Create AWS-S3 UploadHandler permalink

uploadBlob takes two parameter, first file that have io.Reader interface, so almost everything can be uploaded, os.File, multipart.File, and etc. the second paramater is a struct that handle the statistics of the file

type Uploader struct {
  //file length
	Length      int64
  //file content-type
	ContentType string
  //in what folder in the aws-s3 bucket we need to upload
	Prefix      string
  //the file extension
	Ext         string
}

func (a *attachment) UploadBlob(file io.Reader, attach Uploader) (string, error) {
	//TODO: better error handling
	key := attach.Prefix + "/" + util.RandomUUID() + attach.Ext

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_, err := a.uploader.Upload(ctx, &s3.PutObjectInput{
		Bucket:        aws.String(YOUR_BUCKET_NAME),
		Key:           aws.String(key),
		Body:          file,
		ContentLength: attach.Length,
		ContentType:   aws.String(attach.ContentType),
	})
	if err != nil {
		return "", err
	}
	return key, nil
}

and thats it, we returning the key, so if you have the resizing in your aws-s3 that handle resizing or transcoding, you just need to change the prefix, for example, your code is uploading the user image profile and your s3 and aws-lambda resize it for thumbnails and upload it again at prefix profile-picture-resized, you can just added the prefix of resized

import (
  "path/filepath"
)
validKey := filepath.Base(key)
resizedImg := "profile-picture-resized/" + validKey

Create REST-API helper function permalink

before make the REST API Handler, we need to have an helper function that know nothing about the implementation details, such as what is the valid mime types, in what folder in s3 we need this file to upload and we use the interface attachmentManager for the upload for hiding the implementation details and testing purposes

in this helper function we need give our best dx for error handling


func uploadFile(c *gin.Context, uploader attachmentManager, validMimeTypes []string, prefix string) string {
	fileHeader, err := c.FormFile("file")
	if err != nil {
		if errors.Is(err, http.ErrNotMultipart) || errors.Is(err, http.ErrMissingBoundary) {
			errBadRequestResp(c, "content-Type header is not valid") //your custom handler for Bad Request
			return ""
		}
		if errors.Is(err, http.ErrMissingFile) {
			errBadRequestResp(c, "request did not contain a file")
			return ""
		}
		if errors.Is(err, multipart.ErrMessageTooLarge) {
			c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
				"status":  "fail",
				"message": "max byte to upload is 8mB",
			})
			return ""
		}
		errServerResp(c, err)
		return ""
	}

...

and for security purposes, we cant rely on the fileHeader.filename to know what its mimeType is, we need to sniff a little bit (512 bit to be exact) with http.DetectContentType. Because the fileHeader.Open() return an multipart.File that have io.Reader interface, we can read it and copying it to the buffer. io.Reader interface is reading in streaming of bytes, so we cant go back after we read it. but fortunately the multipart.File also implement an io.Seeker interface, so we can go offset the io.Reader byte back to zero

...
  file, err := fileHeader.Open()
	if err != nil {
		errServerResp(c, err)
		return ""
	}
	defer file.Close()

	buff := make([]byte, 512)
	_, err = file.Read(buff)
	if err != nil {
		errServerResp(c, err)
		return ""
	}
	_, err = file.Seek(0, io.SeekStart)
	if err != nil {
		errServerResp(c, err)
		return ""
	}

	var isValidType bool
	contentType := http.DetectContentType(buff)
	for _, validMimeType := range validMimeTypes {
		if contentType == validMimeType {
			isValidType = true
			break
		}
	}
	if !isValidType {
		errUnprocessableEntityResp(c, "not valid mime-type")
		return ""
	}
	key, err := uploader.UploadBlob(file, domain.Uploader{
		Length:      fileHeader.Size,
		ContentType: contentType,
		Prefix:      prefix,
		Ext:         filepath.Ext(fileHeader.Filename),
	})
	if err != nil {
		errServerResp(c, err)
		return ""
	}
	return key
}


So if there is no error, the string is not empty.

Uploading an image using REST-API permalink

After we create the helper function, we can go to the our implementation detail about what file are we gonna upload, in this tutorial we are gonna upload an image

func (u *user) uploadImageHandler(c *gin.Context) {
	var validImageTypes = []string{
		"image/avif",
		"image/jpeg",
		"image/png",
		"image/webp",
		"image/svg+xml",
	}
	key := uploadFile(c, u.attachmentSvc, validImageTypes, "image")
	if key == "" {
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"status":  "success",
		"message": "image uploaded",
		"data": map[string]any{
			"refKey": map[string]any{
				"id": key,
			},
		},
	})
}

we can easily extend it, for example, we upload the profile picture that belong to the user

func (u *user) putUserImageProfile(c *gin.Context) {
	userId := c.GetString("userId") //this from user-middleware that check if its authorized or not
	userDb, err := u.userService.GetUserById(userId)
	if err != nil {
		if errors.Is(err, domain.ErrResourceNotFound) {
			errNotFoundResp(c, "id not found")
			return
		}
		if errors.Is(err, domain.ErrTooLongAccesingDB) {
			errResourceConflictResp(c)
			return
		}
		errServerResp(c, err)
		return
	}
	var validImageTypes = []string{
		"image/avif",
		"image/jpeg",
		"image/png",
		"image/webp",
		"image/svg+xml",
	}
	key := uploadFile(c, u.attachmentSvc, validImageTypes, "profile-picture")
	if key == "" {
		return
	}
	newProfPic := domain.ProfilePicture{ //attach it to the user
		UserId:      userDb.ID,
		PictureLink: key,
	}
	id, err := u.userService.CreateNewProfilePic(newProfPic)
	if err != nil {
		if errors.Is(err, domain.ErrResourceNotFound) {
			errNotFoundResp(c, "users.Id not found!")
			return
		}
		if errors.Is(err, domain.ErrTooLongAccesingDB) {
			errResourceConflictResp(c)
			return
		}
		errServerResp(c, err)
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"status":  "success",
		"message": "user profile-picture uploaded",
		"data": map[string]any{
			"profilePicture": map[string]any{
				"id": id,
			},
		},
	})
}

Thats it, for wiring it up your user controller and aws s3 manager that we have built, we need the main.go

s3Svc := NewS3()
userCtrlr := user.New(s3Svc, anotherSvc)
r := gin.New()
r.MaxMultipartMemory = 8 << 20
v1 := r.Group("/api/v1")
//custom authorization and authentication user
auth := v1.Group("/users/:userId", validateUser(token.Tokenizer)) 
{
  auth.PUT("/profile-picture", userCtrlr.putUserImageProfile)
}
r.NoMethod(noMethod)
r.NoRoute(noFound)
srv := &http.Server{

  Addr:         fmt.Sprintf("0.0.0.0:%d", port),
  Handler:      r,
  IdleTimeout:  time.Minute,
  ReadTimeout:  10 * time.Second,
  WriteTimeout: 30 * time.Second,
}
err := srv.ListenAndServe()

Thats it! hope you guys understand and keep learning