Skip to content

Commit f9be50d

Browse files
author
James Campbell
committed
Add REST API server mode
- New 'serve' subcommand starts HTTP server - POST /extract endpoint for image color extraction - GET /health endpoint for health checks - CORS enabled, configurable port - Updated GitHub Pages with API server and library docs - Updated marquee and feature cards
1 parent fcad471 commit f9be50d

File tree

4 files changed

+359
-5
lines changed

4 files changed

+359
-5
lines changed

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,60 @@ Generates a horizontal color strip with blocks sized proportionally to color pre
123123
- Memory footprint: < 100MB
124124
- Images are automatically downscaled for processing speed
125125

126+
## Run as API Server
127+
128+
Start swatchify as an HTTP server for REST API access:
129+
130+
```bash
131+
swatchify serve --port 8080
132+
```
133+
134+
### Endpoints
135+
136+
**POST /extract** — Extract colors from uploaded image
137+
138+
```bash
139+
# Basic usage
140+
curl -X POST -F "image=@photo.jpg" http://localhost:8080/extract
141+
142+
# With options
143+
curl -X POST \
144+
-F "image=@photo.jpg" \
145+
-F "colors=8" \
146+
-F "quality=100" \
147+
-F "exclude_white=true" \
148+
http://localhost:8080/extract
149+
```
150+
151+
Response:
152+
```json
153+
{
154+
"success": true,
155+
"colors": [
156+
{"hex": "#112233", "percentage": 34.5},
157+
{"hex": "#AABBCC", "percentage": 21.0}
158+
]
159+
}
160+
```
161+
162+
**GET /health** — Health check
163+
164+
```bash
165+
curl http://localhost:8080/health
166+
# {"status": "ok"}
167+
```
168+
169+
### Form Parameters
170+
171+
| Parameter | Type | Default | Description |
172+
|-----------|------|---------|-------------|
173+
| `image` | file | required | Image file to analyze |
174+
| `colors` | int | 5 | Number of colors to extract |
175+
| `quality` | int | 50 | Quality 0-100 |
176+
| `exclude_white` | bool | false | Exclude near-white colors |
177+
| `exclude_black` | bool | false | Exclude near-black colors |
178+
| `min_contrast` | float | 0 | Minimum color distance |
179+
126180
## Use as a Library
127181

128182
Swatchify can be imported and used in your Go code:

cmd/serve.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"strconv"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
"github.com/james-see/swatchify/pkg/swatchify"
14+
)
15+
16+
var (
17+
port int
18+
)
19+
20+
var serveCmd = &cobra.Command{
21+
Use: "serve",
22+
Short: "Start HTTP server for color extraction API",
23+
Long: `Start an HTTP server that provides a REST API for color extraction.
24+
25+
Endpoints:
26+
POST /extract Extract colors from uploaded image
27+
GET /health Health check endpoint
28+
29+
Example:
30+
swatchify serve --port 8080
31+
curl -X POST -F "image=@photo.jpg" http://localhost:8080/extract
32+
curl -X POST -F "image=@photo.jpg" -F "colors=8" http://localhost:8080/extract`,
33+
RunE: runServe,
34+
}
35+
36+
func init() {
37+
rootCmd.AddCommand(serveCmd)
38+
serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port to listen on")
39+
}
40+
41+
func runServe(cmd *cobra.Command, args []string) error {
42+
mux := http.NewServeMux()
43+
44+
// Health check
45+
mux.HandleFunc("/health", handleHealth)
46+
47+
// Extract colors endpoint
48+
mux.HandleFunc("/extract", handleExtract)
49+
50+
// Root info
51+
mux.HandleFunc("/", handleRoot)
52+
53+
addr := fmt.Sprintf(":%d", port)
54+
fmt.Printf("🎨 Swatchify server starting on http://localhost%s\n", addr)
55+
fmt.Println("Endpoints:")
56+
fmt.Println(" POST /extract - Extract colors from image")
57+
fmt.Println(" GET /health - Health check")
58+
fmt.Println()
59+
60+
server := &http.Server{
61+
Addr: addr,
62+
Handler: corsMiddleware(loggingMiddleware(mux)),
63+
ReadTimeout: 30 * time.Second,
64+
WriteTimeout: 30 * time.Second,
65+
}
66+
67+
return server.ListenAndServe()
68+
}
69+
70+
func corsMiddleware(next http.Handler) http.Handler {
71+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+
w.Header().Set("Access-Control-Allow-Origin", "*")
73+
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
74+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
75+
76+
if r.Method == "OPTIONS" {
77+
w.WriteHeader(http.StatusOK)
78+
return
79+
}
80+
81+
next.ServeHTTP(w, r)
82+
})
83+
}
84+
85+
func loggingMiddleware(next http.Handler) http.Handler {
86+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
start := time.Now()
88+
next.ServeHTTP(w, r)
89+
fmt.Printf("%s %s %s %v\n", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
90+
})
91+
}
92+
93+
func handleRoot(w http.ResponseWriter, r *http.Request) {
94+
if r.URL.Path != "/" {
95+
http.NotFound(w, r)
96+
return
97+
}
98+
99+
w.Header().Set("Content-Type", "application/json")
100+
json.NewEncoder(w).Encode(map[string]interface{}{
101+
"name": "swatchify",
102+
"version": "0.2.0",
103+
"endpoints": map[string]string{
104+
"POST /extract": "Extract dominant colors from image",
105+
"GET /health": "Health check",
106+
},
107+
})
108+
}
109+
110+
func handleHealth(w http.ResponseWriter, r *http.Request) {
111+
w.Header().Set("Content-Type", "application/json")
112+
json.NewEncoder(w).Encode(map[string]string{
113+
"status": "ok",
114+
})
115+
}
116+
117+
// ExtractResponse is the JSON response for color extraction
118+
type ExtractResponse struct {
119+
Success bool `json:"success"`
120+
Colors []swatchify.Color `json:"colors,omitempty"`
121+
Error string `json:"error,omitempty"`
122+
}
123+
124+
func handleExtract(w http.ResponseWriter, r *http.Request) {
125+
w.Header().Set("Content-Type", "application/json")
126+
127+
if r.Method != http.MethodPost {
128+
w.WriteHeader(http.StatusMethodNotAllowed)
129+
json.NewEncoder(w).Encode(ExtractResponse{
130+
Success: false,
131+
Error: "Method not allowed. Use POST.",
132+
})
133+
return
134+
}
135+
136+
// Parse multipart form (max 32MB)
137+
if err := r.ParseMultipartForm(32 << 20); err != nil {
138+
w.WriteHeader(http.StatusBadRequest)
139+
json.NewEncoder(w).Encode(ExtractResponse{
140+
Success: false,
141+
Error: "Failed to parse form: " + err.Error(),
142+
})
143+
return
144+
}
145+
146+
// Get uploaded file
147+
file, header, err := r.FormFile("image")
148+
if err != nil {
149+
w.WriteHeader(http.StatusBadRequest)
150+
json.NewEncoder(w).Encode(ExtractResponse{
151+
Success: false,
152+
Error: "No image file provided. Use form field 'image'.",
153+
})
154+
return
155+
}
156+
defer file.Close()
157+
158+
// Create temp file
159+
tmpFile, err := os.CreateTemp("", "swatchify-*"+getExtension(header.Filename))
160+
if err != nil {
161+
w.WriteHeader(http.StatusInternalServerError)
162+
json.NewEncoder(w).Encode(ExtractResponse{
163+
Success: false,
164+
Error: "Failed to create temp file",
165+
})
166+
return
167+
}
168+
defer os.Remove(tmpFile.Name())
169+
defer tmpFile.Close()
170+
171+
// Copy uploaded file to temp
172+
if _, err := io.Copy(tmpFile, file); err != nil {
173+
w.WriteHeader(http.StatusInternalServerError)
174+
json.NewEncoder(w).Encode(ExtractResponse{
175+
Success: false,
176+
Error: "Failed to save uploaded file",
177+
})
178+
return
179+
}
180+
181+
// Parse options from form
182+
opts := parseOptions(r)
183+
184+
// Extract colors
185+
colors, err := swatchify.ExtractFromFile(tmpFile.Name(), opts)
186+
if err != nil {
187+
w.WriteHeader(http.StatusBadRequest)
188+
json.NewEncoder(w).Encode(ExtractResponse{
189+
Success: false,
190+
Error: "Failed to extract colors: " + err.Error(),
191+
})
192+
return
193+
}
194+
195+
json.NewEncoder(w).Encode(ExtractResponse{
196+
Success: true,
197+
Colors: colors,
198+
})
199+
}
200+
201+
func parseOptions(r *http.Request) *swatchify.Options {
202+
opts := swatchify.DefaultOptions()
203+
204+
if n := r.FormValue("colors"); n != "" {
205+
if num, err := strconv.Atoi(n); err == nil && num > 0 && num <= 50 {
206+
opts.NumColors = num
207+
}
208+
}
209+
210+
if q := r.FormValue("quality"); q != "" {
211+
if qual, err := strconv.Atoi(q); err == nil && qual >= 0 && qual <= 100 {
212+
opts.Quality = qual
213+
}
214+
}
215+
216+
if r.FormValue("exclude_white") == "true" || r.FormValue("exclude_white") == "1" {
217+
opts.ExcludeWhite = true
218+
}
219+
220+
if r.FormValue("exclude_black") == "true" || r.FormValue("exclude_black") == "1" {
221+
opts.ExcludeBlack = true
222+
}
223+
224+
if mc := r.FormValue("min_contrast"); mc != "" {
225+
if contrast, err := strconv.ParseFloat(mc, 64); err == nil {
226+
opts.MinContrast = contrast
227+
}
228+
}
229+
230+
return opts
231+
}
232+
233+
func getExtension(filename string) string {
234+
for i := len(filename) - 1; i >= 0; i-- {
235+
if filename[i] == '.' {
236+
return filename[i:]
237+
}
238+
}
239+
return ".tmp"
240+
}
241+

0 commit comments

Comments
 (0)