21 // If true, HTTP request summaries are printed to standard error.
32 ListAll ListMode = "All"
34 ListSubscribed = "Subscribed"
37 var ErrNotFound error = errors.New("not found")
39 func (c *Client) init() error {
41 c.Address = "127.0.0.1"
43 if c.instance == nil {
44 u, err := url.Parse("https://" + c.Address + "/api/v3/")
46 return fmt.Errorf("initialise client: parse instance url: %w", err)
51 c.Client = http.DefaultClient
55 post: make(map[int]entry),
56 community: make(map[string]entry),
64 func (c *Client) Communities(mode ListMode) ([]Community, error) {
66 if err := c.init(); err != nil {
71 params := map[string]string{
72 "type_": string(mode),
73 "limit": "30", // TODO go through pages
76 if mode == ListSubscribed {
77 if c.authToken == "" {
78 return nil, errors.New("not logged in, no subscriptions")
80 params["auth"] = c.authToken
82 resp, err := c.get("community/list", params)
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))
92 Communities []struct {
96 if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
97 return nil, fmt.Errorf("decode community response: %w", err)
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) {
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)
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 {
135 } `json:"community_view"`
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)
144 dur, err := parseMaxAge(age)
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) {
155 if err := c.init(); err != nil {
160 params := map[string]string{
161 "community_name": community,
163 "type_": string(mode),
166 resp, err := c.get("post/list", params)
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 {
186 if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
187 return nil, fmt.Errorf("decode posts response: %w", err)
190 for _, post := range jresponse.Posts {
191 post.Post.Creator = post.Creator
192 posts = append(posts, post.Post)
194 c.cache.store(post.Post, Community{}, ttl)
200 func (c *Client) LookupPost(id int) (Post, error) {
202 if err := c.init(); err != nil {
206 if ent, ok := c.cache.post[id]; ok {
207 if time.Now().Before(ent.expiry) {
210 c.cache.delete(ent.post, Community{})
213 params := map[string]string{"id": strconv.Itoa(id)}
214 resp, err := c.get("post", params)
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)
227 dur, err := parseMaxAge(age)
229 return post, fmt.Errorf("parse cache max age from response header: %w", err)
231 c.cache.store(post, Community{}, dur)
236 func (c *Client) Comments(post int, mode ListMode) ([]Comment, error) {
238 if err := c.init(); err != nil {
243 params := map[string]string{
244 "post_id": strconv.Itoa(post),
245 "type_": string(mode),
249 resp, err := c.get("comment/list", params)
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 {
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)
275 func (c *Client) LookupComment(id int) (Comment, error) {
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)
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 {
295 } `json:"comment_view"`
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{}{
315 params["parent_id"] = strconv.Itoa(parent)
317 resp, err := c.post("/comment", params)
321 defer resp.Body.Close()
322 if resp.StatusCode != http.StatusOK {
323 return fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
328 func (c *Client) post(pathname string, params map[string]interface{}) (*http.Response, error) {
330 u.Path = path.Join(u.Path, pathname)
332 b, err := json.Marshal(params)
334 return nil, fmt.Errorf("encode body: %w", err)
336 req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(b))
340 req.Header.Set("Content-Type", "application/json")
341 req.Header.Set("Accept", "application/json")
345 func (c *Client) get(pathname string, params map[string]string) (*http.Response, error) {
347 u.Path = path.Join(u.Path, pathname)
348 vals := make(url.Values)
349 for k, v := range params {
352 u.RawQuery = vals.Encode()
353 req, err := http.NewRequest(http.MethodGet, u.String(), nil)
357 req.Header.Set("Accept", "application/json")
359 fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL)
361 resp, err := c.Do(req)
365 if resp.StatusCode == http.StatusServiceUnavailable {
366 time.Sleep(2 * time.Second)
367 resp, err = c.get(pathname, params)
373 Err string `json:"error"`
376 func (err jError) Error() string { return err.Err }
378 func decodeError(r io.Reader) error {
380 if err := json.NewDecoder(r).Decode(&jerr); err != nil {
381 return fmt.Errorf("decode error message: %v", err)
389 CommunityID int `json:"community_id"`
390 PostID int `json:"post_id"`