/
tinypng.go
347 lines (270 loc) 路 9.31 KB
/
tinypng.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
// Package tinypng is `tinypng.com` API client implementation.
package tinypng
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// WithoutTimeout is special value for timeouts disabling.
const WithoutTimeout = time.Duration(0)
const shrinkEndpoint = "https://api.tinify.com/shrink" // API endpoint for images shrinking.
// var DefaultClient *Client = NewClient("")
type httpClient interface {
// Do sends an HTTP request and returns an HTTP response.
Do(*http.Request) (*http.Response, error)
}
type Client struct {
// Client context is used for requests making. It allows to cancel any "long" requests or limit them with timeout.
ctx context.Context
// Mutex is used for apiKey concurrent access protection.
mu sync.Mutex
// API key for requests making (get own on <https://tinypng.com/developers>).
apiKey string
// This timeout will be used for requests execution time limiting by default. Set WithoutTimeout (or simply `0`)
// for timeouts disabling (is used by default).
defaultTimeout time.Duration
// HTTP client for requests making.
httpClient httpClient
}
type (
compressionInput struct {
Size uint64 `json:"size"` // eg.: 37745
Type string `json:"type"` // eg.: image/png
}
compressionOutput struct {
Size uint64 `json:"size"` // eg.: 35380
Type string `json:"type"` // eg.: image/png
Width uint64 `json:"width"` // eg.: 512
Height uint64 `json:"height"` // eg.: 512
Ratio float32 `json:"ratio"` // eg.: 0.9373
URL string `json:"url"` // eg.: https://api.tinify.com/output/foobar
}
CompressionResult struct {
Input compressionInput `json:"input"`
Output compressionOutput `json:"output"`
CompressionCount uint64 // used quota value
}
)
// NewClient creates new tinypng client instance. Options can be used to fine client tuning.
func NewClient(apiKey string, options ...ClientOption) *Client {
c := &Client{
apiKey: apiKey,
defaultTimeout: WithoutTimeout,
}
for i := 0; i < len(options); i++ {
options[i](c)
}
if c.ctx == nil {
c.ctx = context.Background()
}
if c.httpClient == nil {
c.httpClient = new(http.Client)
}
return c
}
// SetAPIKey sets API key for requests making.
func (c *Client) SetAPIKey(key string) {
c.mu.Lock()
c.apiKey = key
c.mu.Unlock()
}
// Compress reads image from passed source and compress them on tinypng side. Compressed result will be wrote to the
// passed destination (additional information about compressed image will be returned too).
// You can use two timeouts - first for image uploading and response waiting, and second - for image downloading.
// If the provided src is also an io.Closer - it will be closed automatically by HTTP client (if default HTTP client is
// used).
func (c *Client) Compress(src io.Reader, dest io.Writer, timeouts ...time.Duration) (*CompressionResult, error) {
var compressTimeout, downloadTimeout = c.defaultTimeout, c.defaultTimeout // setup defaults
if len(timeouts) > 0 {
compressTimeout = timeouts[0]
}
result, err := c.compressImage(src, compressTimeout)
if err != nil {
return nil, err
}
if len(timeouts) > 1 {
downloadTimeout = timeouts[1]
}
_, err = c.downloadImage(result.Output.URL, dest, downloadTimeout)
if err != nil {
return nil, err
}
return result, nil
}
// CompressionCount returns compressions count for current API key (used quota value). By default, for free API keys
// quota is equals to 500.
func (c *Client) CompressionCount(timeout ...time.Duration) (uint64, error) {
var t = c.defaultTimeout
if len(timeout) > 0 {
t = timeout[0]
}
return c.compressionCount(t)
}
// CompressImage uploads image content from passed source to the tinypng server for compression. When process is done -
// compression result (just information, not compressed image content) will be returned.
// If the provided src is also an io.Closer - it will be closed automatically by HTTP client (if default HTTP client is
// used).
func (c *Client) CompressImage(src io.Reader, timeout ...time.Duration) (*CompressionResult, error) {
var t = c.defaultTimeout
if len(timeout) > 0 {
t = timeout[0]
}
return c.compressImage(src, t)
}
// DownloadImage from remote server and write to the passed destination. It returns the number of written bytes.
func (c *Client) DownloadImage(url string, dest io.Writer, timeout ...time.Duration) (int64, error) {
var t = c.defaultTimeout
if len(timeout) > 0 {
t = timeout[0]
}
return c.downloadImage(url, dest, t)
}
// requestCtx creates context (with cancellation function) for request making. Do not forget to call cancellation
// function in your code anyway.
func (c *Client) requestCtx(timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout == WithoutTimeout {
return c.ctx, func() {
// do nothing
}
}
return context.WithTimeout(c.ctx, timeout)
}
// compressImage reads image content from src and sends them to the tinypng server with request timeout limitation.
// If the provided src is also an io.Closer - it will be closed automatically by HTTP client (if default HTTP client is
// used).
func (c *Client) compressImage(src io.Reader, timeout time.Duration) (*CompressionResult, error) {
var ctx, cancel = c.requestCtx(timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, shrinkEndpoint, src)
if err != nil {
return nil, err
}
c.setupRequestAuth(req)
req.Header.Set("Accept", "application/json") // is not necessary, but looks correct
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
switch code := resp.StatusCode; {
case code == http.StatusCreated:
var result CompressionResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, errors.New(errorsPrefix + " response decoding failed: " + err.Error())
}
if count, err := c.extractCompressionCount(resp.Header); err == nil { // error will be ignored
result.CompressionCount = count
}
return &result, nil
case code >= 400 && code < 599:
switch code {
case http.StatusUnauthorized:
return nil, ErrUnauthorized
case http.StatusTooManyRequests:
return nil, ErrTooManyRequests
case http.StatusBadRequest:
return nil, ErrBadRequest
default:
return nil, errors.New(errorsPrefix + " " + c.parseServerError(resp.Body).Error())
}
default:
return nil, fmt.Errorf("%s unexpected HTTP response code (%d)", errorsPrefix, code)
}
}
// compressionCount makes "fake" image uploading attempt for "Compression-Count" response header value reading.
func (c *Client) compressionCount(timeout time.Duration) (uint64, error) {
var ctx, cancel = c.requestCtx(timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, shrinkEndpoint, nil)
if err != nil {
return 0, err
}
c.setupRequestAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, err
}
_ = resp.Body.Close()
switch resp.StatusCode {
case http.StatusUnauthorized:
return 0, ErrUnauthorized
default:
return c.extractCompressionCount(resp.Header)
}
}
// setupRequestAuth sets all required properties for HTTP request (eg.: API key).
func (c *Client) setupRequestAuth(request *http.Request) {
c.mu.Lock()
k := c.apiKey
c.mu.Unlock()
request.SetBasicAuth("api", k)
}
// extractCompressionCount extracts `compression-count` header value from HTTP response headers.
func (c *Client) extractCompressionCount(headers http.Header) (uint64, error) {
const headerName = "Compression-Count"
if val, ok := headers[headerName]; ok {
count, err := strconv.ParseUint(val[0], 10, 64)
if err == nil {
return count, nil
}
return 0, fmt.Errorf("%s wrong HTTP header '%s' value: %w", errorsPrefix, headerName, err)
}
return 0, fmt.Errorf("%s HTTP header '%s' was not found", errorsPrefix, headerName)
}
// parseServerError reads HTTP response content as a JSON-string, parse them and converts into go-error. This function
// should never returns nil!
func (c *Client) parseServerError(content io.Reader) error {
var e struct {
Error string `json:"error"`
Message string `json:"message"`
}
if err := json.NewDecoder(content).Decode(&e); err != nil {
return fmt.Errorf("error decoding failed: %w", err)
}
return fmt.Errorf("%s (%s)", e.Error, strings.Trim(e.Message, ". "))
}
// downloadImage downloads image by passed URL (usually from tinypng remote server) with request timeout limitation.
func (c *Client) downloadImage(url string, dest io.Writer, timeout time.Duration) (int64, error) {
var ctx, cancel = c.requestCtx(timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
}
c.setupRequestAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return 0, err
}
defer func() {
_ = resp.Body.Close()
}()
switch code := resp.StatusCode; {
case code == http.StatusOK:
written, err := io.Copy(dest, resp.Body)
if err != nil {
return 0, err
}
return written, nil
case code >= 400 && code < 599:
switch code {
case http.StatusUnauthorized:
return 0, ErrUnauthorized
case http.StatusTooManyRequests:
return 0, ErrTooManyRequests
default:
return 0, errors.New(errorsPrefix + " " + c.parseServerError(resp.Body).Error())
}
default:
return 0, fmt.Errorf("%s unexpected HTTP response code (%d)", errorsPrefix, code)
}
}