1 /* |
|
2 Copyright 2017-2018 Mikael Berthe |
|
3 Copyright 2017 Ollivier Robert |
|
4 |
|
5 Licensed under the MIT license. Please see the LICENSE file is this directory. |
|
6 */ |
|
7 |
|
8 package madon |
|
9 |
|
10 import ( |
|
11 "bytes" |
|
12 "encoding/json" |
|
13 "fmt" |
|
14 "net/http" |
|
15 "net/url" |
|
16 "regexp" |
|
17 "strconv" |
|
18 "strings" |
|
19 "time" |
|
20 |
|
21 "github.com/pkg/errors" |
|
22 "github.com/sendgrid/rest" |
|
23 ) |
|
24 |
|
25 type apiLinks struct { |
|
26 next, prev *LimitParams |
|
27 } |
|
28 |
|
29 func parseLink(links []string) (*apiLinks, error) { |
|
30 if len(links) == 0 { |
|
31 return nil, nil |
|
32 } |
|
33 |
|
34 al := new(apiLinks) |
|
35 linkRegex := regexp.MustCompile(`<([^>]+)>; rel="([^"]+)`) |
|
36 for _, l := range links { |
|
37 m := linkRegex.FindAllStringSubmatch(l, -1) |
|
38 for _, submatch := range m { |
|
39 if len(submatch) != 3 { |
|
40 continue |
|
41 } |
|
42 // Parse URL |
|
43 u, err := url.Parse(submatch[1]) |
|
44 if err != nil { |
|
45 return al, err |
|
46 } |
|
47 var lp *LimitParams |
|
48 since := u.Query().Get("since_id") |
|
49 max := u.Query().Get("max_id") |
|
50 lim := u.Query().Get("limit") |
|
51 if since == "" && max == "" { |
|
52 continue |
|
53 } |
|
54 lp = new(LimitParams) |
|
55 if since != "" { |
|
56 lp.SinceID, err = strconv.ParseInt(since, 10, 64) |
|
57 if err != nil { |
|
58 return al, err |
|
59 } |
|
60 } |
|
61 if max != "" { |
|
62 lp.MaxID, err = strconv.ParseInt(max, 10, 64) |
|
63 if err != nil { |
|
64 return al, err |
|
65 } |
|
66 } |
|
67 if lim != "" { |
|
68 lp.Limit, err = strconv.Atoi(lim) |
|
69 if err != nil { |
|
70 return al, err |
|
71 } |
|
72 } |
|
73 switch submatch[2] { |
|
74 case "prev": |
|
75 al.prev = lp |
|
76 case "next": |
|
77 al.next = lp |
|
78 } |
|
79 } |
|
80 } |
|
81 return al, nil |
|
82 } |
|
83 |
|
84 // restAPI actually does the HTTP query |
|
85 // It is a copy of rest.API with better handling of parameters with multiple values |
|
86 func restAPI(request rest.Request) (*rest.Response, error) { |
|
87 c := &rest.Client{HTTPClient: http.DefaultClient} |
|
88 |
|
89 // Build the HTTP request object. |
|
90 if len(request.QueryParams) != 0 { |
|
91 // Add parameters to the URL |
|
92 request.BaseURL += "?" |
|
93 urlp := url.Values{} |
|
94 arrayRe := regexp.MustCompile(`^\[\d+\](.*)$`) |
|
95 for key, value := range request.QueryParams { |
|
96 // It seems Mastodon doesn't like parameters with index |
|
97 // numbers, but it needs the brackets. |
|
98 // Let's check if the key matches '^.+\[.*\]$' |
|
99 // Do not proceed if there's another bracket pair. |
|
100 klen := len(key) |
|
101 if klen == 0 { |
|
102 continue |
|
103 } |
|
104 if m := arrayRe.FindStringSubmatch(key); len(m) > 0 { |
|
105 // This is an array, let's remove the index number |
|
106 key = m[1] + "[]" |
|
107 } |
|
108 urlp.Add(key, value) |
|
109 } |
|
110 urlpstr := urlp.Encode() |
|
111 request.BaseURL += urlpstr |
|
112 } |
|
113 |
|
114 req, err := http.NewRequest(string(request.Method), request.BaseURL, bytes.NewBuffer(request.Body)) |
|
115 if err != nil { |
|
116 return nil, err |
|
117 } |
|
118 |
|
119 for key, value := range request.Headers { |
|
120 req.Header.Set(key, value) |
|
121 } |
|
122 _, exists := req.Header["Content-Type"] |
|
123 if len(request.Body) > 0 && !exists { |
|
124 req.Header.Set("Content-Type", "application/json") |
|
125 } |
|
126 |
|
127 // Build the HTTP client and make the request. |
|
128 res, err := c.MakeRequest(req) |
|
129 if err != nil { |
|
130 return nil, err |
|
131 } |
|
132 if res.StatusCode < 200 || res.StatusCode >= 300 { |
|
133 var errorText string |
|
134 // Try to unmarshal the returned error object for a description |
|
135 mastodonError := Error{} |
|
136 decodeErr := json.NewDecoder(res.Body).Decode(&mastodonError) |
|
137 if decodeErr != nil { |
|
138 // Decode unsuccessful, fallback to generic error based on response code |
|
139 errorText = http.StatusText(res.StatusCode) |
|
140 } else { |
|
141 errorText = mastodonError.Text |
|
142 } |
|
143 |
|
144 // Please note that the error string code is used by Search() |
|
145 // to check the error cause. |
|
146 const errFormatString = "bad server status code (%d)" |
|
147 return nil, errors.Errorf(errFormatString+": %s", |
|
148 res.StatusCode, errorText) |
|
149 } |
|
150 |
|
151 // Build Response object. |
|
152 response, err := rest.BuildResponse(res) |
|
153 if err != nil { |
|
154 return nil, err |
|
155 } |
|
156 |
|
157 return response, nil |
|
158 } |
|
159 |
|
160 // prepareRequest inserts all pre-defined stuff |
|
161 func (mc *Client) prepareRequest(target string, method rest.Method, params apiCallParams) (rest.Request, error) { |
|
162 var req rest.Request |
|
163 |
|
164 if mc == nil { |
|
165 return req, ErrUninitializedClient |
|
166 } |
|
167 |
|
168 endPoint := mc.APIBase + "/" + target |
|
169 |
|
170 // Request headers |
|
171 hdrs := make(map[string]string) |
|
172 hdrs["User-Agent"] = fmt.Sprintf("madon/%s", MadonVersion) |
|
173 if mc.UserToken != nil { |
|
174 hdrs["Authorization"] = fmt.Sprintf("Bearer %s", mc.UserToken.AccessToken) |
|
175 } |
|
176 |
|
177 req = rest.Request{ |
|
178 BaseURL: endPoint, |
|
179 Headers: hdrs, |
|
180 Method: method, |
|
181 QueryParams: params, |
|
182 } |
|
183 return req, nil |
|
184 } |
|
185 |
|
186 // apiCall makes a call to the Mastodon API server |
|
187 // If links is not nil, the prev/next links from the API response headers |
|
188 // will be set (if they exist) in the structure. |
|
189 func (mc *Client) apiCall(endPoint string, method rest.Method, params apiCallParams, limitOptions *LimitParams, links *apiLinks, data interface{}) error { |
|
190 if mc == nil { |
|
191 return errors.New("use of uninitialized madon client") |
|
192 } |
|
193 |
|
194 if limitOptions != nil { |
|
195 if params == nil { |
|
196 params = make(apiCallParams) |
|
197 } |
|
198 if limitOptions.Limit > 0 { |
|
199 params["limit"] = strconv.Itoa(limitOptions.Limit) |
|
200 } |
|
201 if limitOptions.SinceID > 0 { |
|
202 params["since_id"] = strconv.FormatInt(limitOptions.SinceID, 10) |
|
203 } |
|
204 if limitOptions.MaxID > 0 { |
|
205 params["max_id"] = strconv.FormatInt(limitOptions.MaxID, 10) |
|
206 } |
|
207 } |
|
208 |
|
209 // Prepare query |
|
210 req, err := mc.prepareRequest(endPoint, method, params) |
|
211 if err != nil { |
|
212 return err |
|
213 } |
|
214 |
|
215 // Make API call |
|
216 r, err := restAPI(req) |
|
217 if err != nil { |
|
218 return errors.Wrapf(err, "API query (%s) failed", endPoint) |
|
219 } |
|
220 |
|
221 if links != nil { |
|
222 pLinks, err := parseLink(r.Headers["Link"]) |
|
223 if err != nil { |
|
224 return errors.Wrapf(err, "cannot decode header links (%s)", method) |
|
225 } |
|
226 if pLinks != nil { |
|
227 *links = *pLinks |
|
228 } |
|
229 } |
|
230 |
|
231 // Check for error reply |
|
232 var errorResult Error |
|
233 if err := json.Unmarshal([]byte(r.Body), &errorResult); err == nil { |
|
234 // The empty object is not an error |
|
235 if errorResult.Text != "" { |
|
236 return errors.New(errorResult.Text) |
|
237 } |
|
238 } |
|
239 |
|
240 // Not an error reply; let's unmarshal the data |
|
241 err = json.Unmarshal([]byte(r.Body), &data) |
|
242 if err != nil { |
|
243 return errors.Wrapf(err, "cannot decode API response (%s)", method) |
|
244 } |
|
245 return nil |
|
246 } |
|
247 |
|
248 /* Mastodon timestamp handling */ |
|
249 |
|
250 // MastodonDate is a custom type for the timestamps returned by some API calls |
|
251 // It is used, for example, by 'v1/instance/activity' and 'v2/search'. |
|
252 // The date returned by those Mastodon API calls is a string containing a |
|
253 // timestamp in seconds... |
|
254 |
|
255 // UnmarshalJSON handles deserialization for custom MastodonDate type |
|
256 func (act *MastodonDate) UnmarshalJSON(b []byte) error { |
|
257 s, err := strconv.ParseInt(strings.Trim(string(b), "\""), 10, 64) |
|
258 if err != nil { |
|
259 return err |
|
260 } |
|
261 if s == 0 { |
|
262 act.Time = time.Time{} |
|
263 return nil |
|
264 } |
|
265 act.Time = time.Unix(s, 0) |
|
266 return nil |
|
267 } |
|
268 |
|
269 // MarshalJSON handles serialization for custom MastodonDate type |
|
270 func (act *MastodonDate) MarshalJSON() ([]byte, error) { |
|
271 return []byte(fmt.Sprintf("\"%d\"", act.Unix())), nil |
|
272 } |
|