forked from rockorager/go-jmap
-
Notifications
You must be signed in to change notification settings - Fork 0
/
client.go
301 lines (263 loc) · 7.54 KB
/
client.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
package jmap
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"golang.org/x/oauth2"
)
// A JMAP Client
type Client struct {
sync.Mutex
// The HttpClient.Client to use for requests. The HttpClient.Client should handle
// authentication. Calling WithBasicAuth or WithAccessToken on the
// Client will set the HttpClient to one which uses authentication
HttpClient *http.Client
// The JMAP Session Resource Endpoint. If the client detects the Session
// object needs refetching, it will automatically do so.
SessionEndpoint string
// the JMAP Session object
Session *Session
}
// Set the HttpClient to a client which authenticates using the provided
// username and password
func (c *Client) WithBasicAuth(username string, password string) *Client {
ctx := context.Background()
auth := username + ":" + password
t := &oauth2.Token{
AccessToken: base64.StdEncoding.EncodeToString([]byte(auth)),
TokenType: "basic",
}
cfg := &oauth2.Config{}
c.HttpClient = oauth2.NewClient(ctx, cfg.TokenSource(ctx, t))
return c
}
// Set the HttpClient to a client which authenticates using the provided Access
// Token
func (c *Client) WithAccessToken(token string) *Client {
ctx := context.Background()
t := &oauth2.Token{
AccessToken: token,
TokenType: "bearer",
}
cfg := &oauth2.Config{}
c.HttpClient = oauth2.NewClient(ctx, cfg.TokenSource(ctx, t))
return c
}
// Authenticate authenticates the client and retrieves the Session object.
// Authenticate will be called automatically when Do is called if the Session
// object hasn't already been initialized. Call Authenticate before any requests
// if you need to access information from the Session object prior to the first
// request
func (c *Client) Authenticate() error {
c.Lock()
if c.SessionEndpoint == "" {
c.Unlock()
return fmt.Errorf("no session url is set")
}
c.Unlock()
req, err := http.NewRequest("GET", c.SessionEndpoint, nil)
if err != nil {
return err
}
resp, err := c.HttpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("couldn't authenticate")
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
s := &Session{}
err = json.Unmarshal(data, s)
if err != nil {
return err
}
c.Session = s
return nil
}
// Do performs a JMAP request and returns the response
func (c *Client) Do(req *Request) (*Response, error) {
c.Lock()
if c.Session == nil {
c.Unlock()
err := c.Authenticate()
if err != nil {
return nil, err
}
}
c.Unlock()
// Check the required capabilities before making the request
for _, uri := range req.Using {
c.Lock()
_, ok := c.Session.Capabilities[uri]
c.Unlock()
if !ok {
return nil, fmt.Errorf("server doesn't support required capability '%s'", uri)
}
}
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
if req.Context == nil {
req.Context = context.Background()
}
httpReq, err := http.NewRequestWithContext(req.Context, "POST", c.Session.APIURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpResp, err := c.HttpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
if httpResp.StatusCode != 200 {
return nil, decodeHttpError(httpResp)
}
data, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
resp := &Response{}
err = json.Unmarshal(data, resp)
if err != nil {
return nil, fmt.Errorf("error? %v", err)
}
return resp, nil
}
// Upload sends binary data to the server and returns blob ID and some
// associated meta-data.
//
// There are some caveats to keep in mind:
// - Server may return the same blob ID for multiple uploads of the same blob.
// - Blob ID may become invalid after some time if it is unused.
// - Blob ID is usable only by the uploader until it is used, even for shared accounts.
func (c *Client) Upload(accountID ID, blob io.Reader) (*UploadResponse, error) {
return c.UploadWithContext(context.Background(), accountID, blob)
}
// UploadWithContext sends binary data to the server and returns blob ID and
// some associated meta-data.
//
// There are some caveats to keep in mind:
// - Server may return the same blob ID for multiple uploads of the same blob.
// - Blob ID may become invalid after some time if it is unused.
// - Blob ID is usable only by the uploader until it is used, even for shared accounts.
func (c *Client) UploadWithContext(
ctx context.Context, accountID ID, blob io.Reader,
) (*UploadResponse, error) {
c.Lock()
if c.SessionEndpoint == "" {
c.Unlock()
return nil, fmt.Errorf("jmap/client: SessionEndpoint is empty")
}
if c.Session == nil {
c.Unlock()
err := c.Authenticate()
if err != nil {
return nil, err
}
}
url := strings.ReplaceAll(c.Session.UploadURL, "{accountId}", string(accountID))
c.Unlock()
req, err := http.NewRequestWithContext(ctx, "POST", url, blob)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return nil, decodeHttpError(resp)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
info := &UploadResponse{}
err = json.Unmarshal(data, info)
if err != nil {
return nil, err
}
return info, nil
}
// Download downloads binary data by its Blob ID from the server.
func (c *Client) Download(accountID ID, blobID ID) (io.ReadCloser, error) {
return c.DownloadWithContext(context.Background(), accountID, blobID)
}
// DownloadWithContext downloads binary data by its Blob ID from the server.
func (c *Client) DownloadWithContext(
ctx context.Context, accountID ID, blobID ID,
) (io.ReadCloser, error) {
c.Lock()
if c.SessionEndpoint == "" {
c.Unlock()
return nil, fmt.Errorf("jmap/client: SessionEndpoint is empty")
}
if c.Session == nil {
c.Unlock()
err := c.Authenticate()
if err != nil {
return nil, err
}
}
urlRepl := strings.NewReplacer(
"{accountId}", string(accountID),
"{blobId}", string(blobID),
"{type}", "application/octet-stream",
"{name}", "filename",
)
tgtUrl := urlRepl.Replace(c.Session.DownloadURL)
c.Unlock()
req, err := http.NewRequestWithContext(ctx, "GET", tgtUrl, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
defer resp.Body.Close()
return nil, decodeHttpError(resp)
}
return resp.Body, nil
}
func decodeHttpError(resp *http.Response) error {
contentType := resp.Header.Get("Content-Type")
if contentType != "application/json" {
return fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
}
reqErr := &RequestError{}
if err := json.NewDecoder(resp.Body).Decode(reqErr); err != nil {
return fmt.Errorf("HTTP %d %s (failed to decode JSON body: %v)", resp.StatusCode, resp.Status, err)
}
return reqErr
}
// UploadResponse is the object returned in response to blob upload.
type UploadResponse struct {
// The id of the account used for the call.
Account ID `json:"accountId"`
// The id representing the binary data uploaded. The data for this id is
// immutable. The id only refers to the binary data, not any metadata.
ID ID `json:"blobId"`
// The media type of the file (as specified in RFC 6838, section 4.2) as
// set in the Content-Type header of the upload HTTP request.
Type string `json:"type"`
// The size of the file in octets.
Size uint64 `json:"size"`
}