go
57 lines · 9 steps
Resumable chunked uploads in Gin
A Gin handler writes each byte range of a file to its correct offset and finalizes once the whole thing has arrived.
Explained by
highlit
1func UploadChunk(c *gin.Context) {
2 uploadID := c.Param("uploadID")
3 if !validUploadID.MatchString(uploadID) {
4 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload id"})
5 return
6 }
7
8 start, end, total, err := parseContentRange(c.GetHeader("Content-Range"))
9 if err != nil {
10 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
11 return
12 }
13
14 path := filepath.Join(uploadDir, uploadID+".part")
15 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
16 if err != nil {
17 c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open upload"})
18 return
19 }
20 defer f.Close()
21
22 if _, err := f.Seek(start, io.SeekStart); err != nil {
23 c.JSON(http.StatusInternalServerError, gin.H{"error": "seek failed"})
24 return
25 }
26
27 written, err := io.CopyN(f, c.Request.Body, end-start+1)
28 if err != nil && err != io.EOF {
29 c.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
30 return
31 }
32
33 if start+written >= total {
34 if err := os.Rename(path, filepath.Join(uploadDir, uploadID)); err != nil {
35 c.JSON(http.StatusInternalServerError, gin.H{"error": "finalize failed"})
36 return
37 }
38 c.JSON(http.StatusCreated, gin.H{"uploadID": uploadID, "size": total, "complete": true})
39 return
40 }
41
42 c.Header("Range", fmt.Sprintf("bytes=0-%d", start+written-1))
43 c.JSON(http.StatusAccepted, gin.H{"uploadID": uploadID, "received": start + written, "total": total})
44}
45
46func parseContentRange(h string) (start, end, total int64, err error) {
47 if !strings.HasPrefix(h, "bytes ") {
48 return 0, 0, 0, fmt.Errorf("missing Content-Range header")
49 }
50 if _, err = fmt.Sscanf(h, "bytes %d-%d/%d", &start, &end, &total); err != nil {
51 return 0, 0, 0, fmt.Errorf("malformed Content-Range")
52 }
53 if start < 0 || end < start || end >= total {
54 return 0, 0, 0, fmt.Errorf("invalid range bounds")
55 }
56 return start, end, total, nil
57}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Writing chunks at their declared offset lets uploads resume without buffering the whole file in memory.
- 2Staging to a .part file and renaming on completion gives an atomic, all-or-nothing finalize.
- 3Every piece of client-supplied input — the id, the range header, the bounds — must be validated before it touches disk.
Related explainers
go
package streaming import ( "bufio"
Streaming NDJSON logs over HTTP in Go
http-streaming
channels
select
Advanced
10 steps
go
package api import ( "crypto/sha256"
ETag conditional requests in Gin
http-caching
etag
conditional-requests
Intermediate
6 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
python
import random from typing import Iterator, List
How reservoir sampling picks k items
reservoir-sampling
streaming
randomness
Intermediate
5 steps
java
public static Map<String, String> parseCookieHeader(String header) { Map<String, String> cookies = new LinkedHashMap<>(); if (header == null || header.isBlank()) { return cookies;
Parsing an HTTP Cookie header in Java
string-parsing
http
url-decoding
Intermediate
6 steps
go
package logging import ( "context"
Deduplicating log attributes in Go's slog
decorator-pattern
structured-logging
immutability
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/resumable-chunked-uploads-in-gin-explained-go-4fcd/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.