Blob


1 package lemmy
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "os"
12 "path"
13 "strconv"
14 "sync"
15 "time"
16 )
18 type Client struct {
19 *http.Client
20 Address string
21 // If true, HTTP request summaries are printed to standard error.
22 Debug bool
23 authToken string
24 instance *url.URL
25 cache *cache
26 ready bool
27 }
29 type ListMode string
31 const (
32 ListAll ListMode = "All"
33 ListLocal = "Local"
34 ListSubscribed = "Subscribed"
35 )
37 var ErrNotFound error = errors.New("not found")
39 func (c *Client) init() error {
40 if c.Address == "" {
41 c.Address = "127.0.0.1"
42 }
43 if c.instance == nil {
44 u, err := url.Parse("https://" + c.Address + "/api/v3/")
45 if err != nil {
46 return fmt.Errorf("initialise client: parse instance url: %w", err)
47 }
48 c.instance = u
49 }
50 if c.Client == nil {
51 c.Client = http.DefaultClient
52 }
53 if c.cache == nil {
54 c.cache = &cache{
55 post: make(map[int]entry),
56 community: make(map[string]entry),
57 mu: &sync.Mutex{},
58 }
59 }
60 c.ready = true
61 return nil
62 }
64 func (c *Client) Communities(mode ListMode) ([]Community, error) {
65 if !c.ready {
66 if err := c.init(); err != nil {
67 return nil, err
68 }
69 }
71 params := map[string]string{
72 "type_": string(mode),
73 "limit": "30", // TODO go through pages
74 "sort": "New",
75 }
76 if mode == ListSubscribed {
77 if c.authToken == "" {
78 return nil, errors.New("not logged in, no subscriptions")
79 }
80 params["auth"] = c.authToken
81 }
82 resp, err := c.get("community/list", params)
83 if err != nil {
84 return nil, err
85 }
86 defer resp.Body.Close()
87 if resp.StatusCode != http.StatusOK {
88 return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
89 }
91 var response struct {
92 Communities []struct {
93 Community Community
94 }
95 }
96 if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
97 return nil, fmt.Errorf("decode community response: %w", err)
98 }
99 var communities []Community
100 for _, c := range response.Communities {
101 communities = append(communities, c.Community)
103 return communities, nil
106 func (c *Client) LookupCommunity(name string) (Community, Counts, error) {
107 if !c.ready {
108 if err := c.init(); err != nil {
109 return Community{}, Counts{}, err
112 if ent, ok := c.cache.community[name]; ok {
113 if time.Now().Before(ent.expiry) {
114 return ent.community, Counts{}, nil
116 c.cache.delete(ent.post, ent.community)
119 params := map[string]string{"name": name}
120 resp, err := c.get("community", params)
121 if err != nil {
122 return Community{}, Counts{}, err
124 defer resp.Body.Close()
125 if resp.StatusCode == http.StatusNotFound {
126 return Community{}, Counts{}, ErrNotFound
127 } else if resp.StatusCode != http.StatusOK {
128 return Community{}, Counts{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
131 type response struct {
132 View struct {
133 Community Community
134 Counts Counts
135 } `json:"community_view"`
137 var cres response
138 if err := json.NewDecoder(resp.Body).Decode(&cres); err != nil {
139 return Community{}, Counts{}, fmt.Errorf("decode community response: %w", err)
141 community := cres.View.Community
142 age := extractMaxAge(resp.Header)
143 if age != "" {
144 dur, err := parseMaxAge(age)
145 if err != nil {
146 return community, Counts{}, fmt.Errorf("parse cache max age from response header: %w", err)
148 c.cache.store(Post{}, community, dur)
150 return community, cres.View.Counts, nil
153 func (c *Client) Posts(community string, mode ListMode) ([]Post, error) {
154 if !c.ready {
155 if err := c.init(); err != nil {
156 return nil, err
160 params := map[string]string{
161 "community_name": community,
162 // "limit": "30",
163 "type_": string(mode),
164 "sort": "New",
166 resp, err := c.get("post/list", params)
167 if err != nil {
168 return nil, err
170 defer resp.Body.Close()
171 if resp.StatusCode != http.StatusOK {
172 return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
174 age := extractMaxAge(resp.Header)
175 ttl, err := parseMaxAge(age)
176 if c.Debug && err != nil {
177 fmt.Fprintln(os.Stderr, "parse cache max-age from header:", err)
180 var jresponse struct {
181 Posts []struct {
182 Post Post
183 Creator Person
186 if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
187 return nil, fmt.Errorf("decode posts response: %w", err)
189 var posts []Post
190 for _, post := range jresponse.Posts {
191 post.Post.Creator = post.Creator
192 posts = append(posts, post.Post)
193 if ttl > 0 {
194 c.cache.store(post.Post, Community{}, ttl)
197 return posts, nil
200 func (c *Client) LookupPost(id int) (Post, error) {
201 if !c.ready {
202 if err := c.init(); err != nil {
203 return Post{}, err
206 if ent, ok := c.cache.post[id]; ok {
207 if time.Now().Before(ent.expiry) {
208 return ent.post, nil
210 c.cache.delete(ent.post, Community{})
213 params := map[string]string{"id": strconv.Itoa(id)}
214 resp, err := c.get("post", params)
215 if err != nil {
216 return Post{}, err
218 defer resp.Body.Close()
219 if resp.StatusCode == http.StatusNotFound {
220 return Post{}, ErrNotFound
221 } else if resp.StatusCode != http.StatusOK {
222 return Post{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
224 post, _, _, err := decodePostResponse(resp.Body)
225 age := extractMaxAge(resp.Header)
226 if age != "" {
227 dur, err := parseMaxAge(age)
228 if err != nil {
229 return post, fmt.Errorf("parse cache max age from response header: %w", err)
231 c.cache.store(post, Community{}, dur)
233 return post, err
236 func (c *Client) Comments(post int, mode ListMode) ([]Comment, error) {
237 if !c.ready {
238 if err := c.init(); err != nil {
239 return nil, err
243 params := map[string]string{
244 "post_id": strconv.Itoa(post),
245 "type_": string(mode),
246 "limit": "30",
247 "sort": "New",
249 resp, err := c.get("comment/list", params)
250 if err != nil {
251 return nil, err
253 defer resp.Body.Close()
254 if resp.StatusCode != http.StatusOK {
255 return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
258 var jresponse struct {
259 Comments []struct {
260 Comment Comment
261 Creator Person
264 if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
265 return nil, fmt.Errorf("decode comments: %w", err)
267 var comments []Comment
268 for _, comment := range jresponse.Comments {
269 comment.Comment.Creator = comment.Creator
270 comments = append(comments, comment.Comment)
272 return comments, nil
275 func (c *Client) LookupComment(id int) (Comment, error) {
276 if !c.ready {
277 if err := c.init(); err != nil {
278 return Comment{}, err
282 params := map[string]string{"id": strconv.Itoa(id)}
283 resp, err := c.get("comment", params)
284 if err != nil {
285 return Comment{}, err
287 defer resp.Body.Close()
288 if resp.StatusCode != http.StatusOK {
289 return Comment{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
292 type jresponse struct {
293 CommentView struct {
294 Comment Comment
295 } `json:"comment_view"`
297 var jresp jresponse
298 if err := json.NewDecoder(resp.Body).Decode(&jresp); err != nil {
299 return Comment{}, fmt.Errorf("decode comment: %w", err)
301 return jresp.CommentView.Comment, nil
304 func (c *Client) Reply(post int, parent int, msg string) error {
305 if c.authToken == "" {
306 return errors.New("not logged in")
309 params := map[string]interface{}{
310 "post_id": post,
311 "content": msg,
312 "auth": c.authToken,
314 if parent > 0 {
315 params["parent_id"] = strconv.Itoa(parent)
317 resp, err := c.post("/comment", params)
318 if err != nil {
319 return err
321 defer resp.Body.Close()
322 if resp.StatusCode != http.StatusOK {
323 return fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
325 return nil
328 func (c *Client) post(pathname string, params map[string]interface{}) (*http.Response, error) {
329 u := *c.instance
330 u.Path = path.Join(u.Path, pathname)
332 b, err := json.Marshal(params)
333 if err != nil {
334 return nil, fmt.Errorf("encode body: %w", err)
336 req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(b))
337 if err != nil {
338 return nil, err
340 req.Header.Set("Content-Type", "application/json")
341 req.Header.Set("Accept", "application/json")
342 return c.Do(req)
345 func (c *Client) get(pathname string, params map[string]string) (*http.Response, error) {
346 u := *c.instance
347 u.Path = path.Join(u.Path, pathname)
348 vals := make(url.Values)
349 for k, v := range params {
350 vals.Set(k, v)
352 u.RawQuery = vals.Encode()
353 req, err := http.NewRequest(http.MethodGet, u.String(), nil)
354 if err != nil {
355 return nil, err
357 req.Header.Set("Accept", "application/json")
358 if c.Debug {
359 fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL)
361 resp, err := c.Do(req)
362 if err != nil {
363 return resp, err
365 if resp.StatusCode == http.StatusServiceUnavailable {
366 time.Sleep(2 * time.Second)
367 resp, err = c.get(pathname, params)
369 return resp, err
372 type jError struct {
373 Err string `json:"error"`
376 func (err jError) Error() string { return err.Err }
378 func decodeError(r io.Reader) error {
379 var jerr jError
380 if err := json.NewDecoder(r).Decode(&jerr); err != nil {
381 return fmt.Errorf("decode error message: %v", err)
383 return jerr
386 type Counts struct {
387 Posts int
388 Comments int
389 CommunityID int `json:"community_id"`
390 PostID int `json:"post_id"`