Skip to content

Commit 9833368

Browse files
committed
feat: implement secure file upload API with strict size limits and tests
- Add a Gin-based file upload server with strict 1MB upload limit enforced by http.MaxBytesReader and custom error responses - Include unit and integration tests for valid upload, oversized file rejection, and missing file cases - Provide documentation explaining setup, API usage, error handling, testing, and how to modify the upload size limit Signed-off-by: appleboy <[email protected]>
1 parent c476f4a commit 9833368

File tree

3 files changed

+220
-0
lines changed

3 files changed

+220
-0
lines changed

upload-limit-bytes/README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Gin Example: Restrict File Upload Size with http.MaxBytesReader
2+
3+
This example demonstrates how to use [Gin](https://github.com/gin-gonic/gin) and Go's `http.MaxBytesReader` to strictly limit uploaded file size and return custom error messages when the size limit is exceeded.
4+
5+
## Features
6+
7+
- REST API - POST `/upload` endpoint for file upload.
8+
- Server-side enforcement of maximum file size (default: **1MB**).
9+
- Immediate error response (`413 Request Entity Too Large` with custom JSON) when file is too large.
10+
- Well-commented code for clarity.
11+
- Unit + integration tests verifying successful uploads, oversized file rejection, and edge cases.
12+
13+
## How It Works
14+
15+
- The core implementation wraps the incoming request body using Go's [`http.MaxBytesReader`](https://pkg.go.dev/net/http#MaxBytesReader), which limits how many bytes the server will read.
16+
- If a client uploads a file exceeding this limit, parsing fails and Gin responds with a **custom error message**.
17+
- See [`main.go`](./main.go) for details and comments.
18+
19+
## API Usage
20+
21+
### Start the server
22+
23+
```bash
24+
go run main.go
25+
```
26+
27+
Server listens on port **8080**.
28+
29+
### Upload a file
30+
31+
**cURL valid file (within 1MB):**
32+
33+
```bash
34+
curl -F "[email protected]" http://localhost:8080/upload
35+
# Response: {"message":"upload successful"}
36+
```
37+
38+
**cURL oversized file (e.g., 2MB):**
39+
40+
```bash
41+
curl -F "[email protected]" http://localhost:8080/upload
42+
# Response: {"error":"file too large (max: 1048576 bytes)"}
43+
```
44+
45+
### Error cases
46+
47+
- Missing file: `{"error":"file form required"}` (status 400)
48+
- File too large: `{"error":"file too large (max: 1048576 bytes)"}` (status 413)
49+
50+
## Testing
51+
52+
This example includes unit/integration tests in [`main_test.go`](./main_test.go):
53+
54+
```bash
55+
go test
56+
```
57+
58+
- **TestUploadWithinLimit**: uploads a file under the limit, expects success.
59+
- **TestUploadOverLimit**: uploads a file over the limit, expects a custom error.
60+
- **TestUploadMissingFile**: no file uploaded, expects validation error.
61+
62+
## Code Reference
63+
64+
- [`main.go`](./main.go): Gin server setup, upload handler with file size enforcement.
65+
- [`main_test.go`](./main_test.go): Contains all unit and integration tests.
66+
67+
## Modify the Limit
68+
69+
Change `MaxUploadSize` constant in [`main.go`](./main.go) and [`main_test.go`](./main_test.go) to test other limits.
70+
71+
---
72+
73+
**License:** MIT
74+
**Author:** Gin Example Contributors

upload-limit-bytes/main.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// upload-limit-bytes/main.go
2+
// Example: Restrict file upload size using Gin + http.MaxBytesReader
3+
//
4+
// This example shows how to use Gin together with http.MaxBytesReader to safely and *strictly*
5+
// limit the maximum size of uploaded files. It returns a custom error message if the file is too large.
6+
7+
package main
8+
9+
import (
10+
"fmt"
11+
"net/http"
12+
13+
"github.com/gin-gonic/gin"
14+
)
15+
16+
const (
17+
MaxUploadSize = 1 << 20 // 1MB (can set to other sizes; used for tests and demos)
18+
)
19+
20+
func uploadHandler(c *gin.Context) {
21+
// Wrap the body reader so only MaxUploadSize bytes are allowed
22+
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxUploadSize)
23+
24+
// Parse multipart form (limit maxMemory for demonstration, not for size restriction)
25+
if err := c.Request.ParseMultipartForm(MaxUploadSize); err != nil {
26+
// Check for *http.MaxBytesError for upload size limit exceeded (portable way)
27+
if _, ok := err.(*http.MaxBytesError); ok {
28+
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
29+
"error": fmt.Sprintf("file too large (max: %d bytes)", MaxUploadSize),
30+
})
31+
return
32+
}
33+
// Other form errors
34+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
35+
return
36+
}
37+
38+
// Get uploaded file; parameter name is "file"
39+
file, _, err := c.Request.FormFile("file")
40+
if err != nil {
41+
c.JSON(http.StatusBadRequest, gin.H{"error": "file form required"})
42+
return
43+
}
44+
45+
defer file.Close()
46+
// For demonstration we ignore saving the file, but could copy to disk or buffer
47+
// _, err = io.Copy(io.Discard, file) // Uncomment if you want to read all bytes
48+
49+
c.JSON(http.StatusOK, gin.H{
50+
"message": "upload successful",
51+
})
52+
}
53+
54+
// setupRouter creates and returns the Gin router
55+
func setupRouter() *gin.Engine {
56+
r := gin.Default()
57+
r.POST("/upload", uploadHandler)
58+
return r
59+
}
60+
61+
func main() {
62+
r := setupRouter()
63+
// Run Gin server on :8080
64+
r.Run(":8080")
65+
}

upload-limit-bytes/main_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// upload-limit-bytes/main_test.go
2+
// Unit & integration tests for Gin file upload with http.MaxBytesReader limit
3+
4+
package main
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"io"
10+
"mime/multipart"
11+
"net/http"
12+
"net/http/httptest"
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// Use setupRouter and uploadHandler from the main package.
19+
// (Removed local setupRouter to avoid redeclaration; tests use main's setupRouter().)
20+
21+
// Helper: create multipart body with given size
22+
func createMultipartBody(fieldName string, size int) (contentType string, bodyBytes []byte) {
23+
var b bytes.Buffer
24+
w := multipart.NewWriter(&b)
25+
fw, _ := w.CreateFormFile(fieldName, "test.bin")
26+
io.CopyN(fw, bytes.NewReader(make([]byte, size)), int64(size)) // Write 'size' bytes
27+
w.Close()
28+
return w.FormDataContentType(), b.Bytes()
29+
}
30+
31+
func TestUploadWithinLimit(t *testing.T) {
32+
router := setupRouter()
33+
// Prepare file just under max size
34+
// Reduce file size so the total request (including multipart headers/overhead) is safely below the limit.
35+
contentType, body := createMultipartBody("file", MaxUploadSize-10*1024)
36+
req, _ := http.NewRequest("POST", "/upload", bytes.NewReader(body))
37+
req.Header.Set("Content-Type", contentType)
38+
resp := httptest.NewRecorder()
39+
40+
router.ServeHTTP(resp, req)
41+
42+
require.Equal(t, http.StatusOK, resp.Code)
43+
var result map[string]string
44+
err := json.Unmarshal(resp.Body.Bytes(), &result)
45+
require.NoError(t, err)
46+
require.Equal(t, "upload successful", result["message"])
47+
}
48+
49+
func TestUploadOverLimit(t *testing.T) {
50+
router := setupRouter()
51+
contentType, body := createMultipartBody("file", MaxUploadSize+100)
52+
req, _ := http.NewRequest("POST", "/upload", bytes.NewReader(body))
53+
req.Header.Set("Content-Type", contentType)
54+
resp := httptest.NewRecorder()
55+
56+
router.ServeHTTP(resp, req)
57+
58+
require.Equal(t, http.StatusRequestEntityTooLarge, resp.Code)
59+
var result map[string]string
60+
err := json.Unmarshal(resp.Body.Bytes(), &result)
61+
require.NoError(t, err)
62+
require.Contains(t, result["error"], "file too large")
63+
}
64+
65+
func TestUploadMissingFile(t *testing.T) {
66+
router := setupRouter()
67+
var b bytes.Buffer
68+
w := multipart.NewWriter(&b)
69+
w.Close()
70+
req, _ := http.NewRequest("POST", "/upload", &b)
71+
req.Header.Set("Content-Type", w.FormDataContentType())
72+
resp := httptest.NewRecorder()
73+
74+
router.ServeHTTP(resp, req)
75+
76+
require.Equal(t, http.StatusBadRequest, resp.Code)
77+
var result map[string]string
78+
err := json.Unmarshal(resp.Body.Bytes(), &result)
79+
require.NoError(t, err)
80+
require.Contains(t, result["error"], "file form required")
81+
}

0 commit comments

Comments
 (0)