package client import ( "bytes" "context" "encoding/json" "fmt" "io" "mime" "mime/multipart" "net/http" "net/textproto" "net/url" "strconv" "strings" ) func journalCollectionPath(gardenID int) string { return "v1/gardens/" + strconv.Itoa(gardenID) + "/journal" } // Supported journal entry types. const ( JournalEntryTypeJournal = "journal" JournalEntryTypePinboard = "pinboard" ) func journalEntryPath(gardenID, entryID int) string { return journalCollectionPath(gardenID) + "/" + strconv.Itoa(entryID) } func journalAttachmentPath(gardenID, entryID, attachmentID int) string { return journalEntryPath(gardenID, entryID) + "/attachments/" + strconv.Itoa(attachmentID) } // CreateJournalEntry adds a journal or pinboard entry to a garden. func (c *Client) CreateJournalEntry(ctx context.Context, gardenID int, input JournalEntryInput) (JournalEntry, *Response, error) { return c.writeJournalEntry(ctx, http.MethodPost, journalCollectionPath(gardenID), input) } // JournalEntries lists all journal and pinboard entries in a garden. func (c *Client) JournalEntries(ctx context.Context, gardenID int) ([]JournalEntry, *Response, error) { return c.JournalEntriesByType(ctx, gardenID, JournalEntryTypeJournal) } // JournalEntriesByType lists garden entries of one journal entry type. func (c *Client) JournalEntriesByType(ctx context.Context, gardenID int, entryType string) ([]JournalEntry, *Response, error) { var envelope struct { JournalEntries []JournalEntry `json:"journal_entries"` } path := journalCollectionPath(gardenID) if entryType != "" && entryType != JournalEntryTypeJournal { path += "?type=" + url.QueryEscape(entryType) } response, err := c.do(ctx, http.MethodGet, path, nil, &envelope) return envelope.JournalEntries, response, err } // GardenTags lists tag names available within a garden. func (c *Client) GardenTags(ctx context.Context, gardenID int) ([]string, *Response, error) { var envelope struct { Tags []string `json:"tags"` } response, err := c.do(ctx, http.MethodGet, "v1/gardens/"+strconv.Itoa(gardenID)+"/tags", nil, &envelope) return envelope.Tags, response, err } // JournalEntry returns one entry within its garden. func (c *Client) JournalEntry(ctx context.Context, gardenID, entryID int) (JournalEntry, *Response, error) { var envelope struct { JournalEntry JournalEntry `json:"journal_entry"` } response, err := c.do(ctx, http.MethodGet, journalEntryPath(gardenID, entryID), nil, &envelope) return envelope.JournalEntry, response, err } // UpdateJournalEntry changes an entry within its garden. func (c *Client) UpdateJournalEntry(ctx context.Context, gardenID, entryID int, input JournalEntryInput) (JournalEntry, *Response, error) { return c.writeJournalEntry(ctx, http.MethodPatch, journalEntryPath(gardenID, entryID), input) } func (c *Client) writeJournalEntry(ctx context.Context, method, path string, input JournalEntryInput) (JournalEntry, *Response, error) { var envelope struct { JournalEntry JournalEntry `json:"journal_entry"` } response, err := c.do(ctx, method, path, input, &envelope) return envelope.JournalEntry, response, err } // DeleteJournalEntry removes an entry and its attachment records. func (c *Client) DeleteJournalEntry(ctx context.Context, gardenID, entryID int) (*Response, error) { return c.do(ctx, http.MethodDelete, journalEntryPath(gardenID, entryID), nil, nil) } // UploadJournalAttachment adds binary media to an entry. func (c *Client) UploadJournalAttachment(ctx context.Context, gardenID, entryID int, fileName, mediaType string, data []byte) (JournalAttachment, *Response, error) { var body bytes.Buffer w := multipart.NewWriter(&body) header := make(textproto.MIMEHeader) header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": fileName})) header.Set("Content-Type", mediaType) part, err := w.CreatePart(header) if err != nil { return JournalAttachment{}, nil, err } if _, err = part.Write(data); err != nil { return JournalAttachment{}, nil, err } if err = w.Close(); err != nil { return JournalAttachment{}, nil, err } response, responseBody, err := c.doBinary(ctx, http.MethodPost, journalEntryPath(gardenID, entryID)+"/attachments", &body, w.FormDataContentType(), 2<<20) if err != nil { return JournalAttachment{}, response, err } var envelope struct { Attachment JournalAttachment `json:"attachment"` } if err = json.Unmarshal(responseBody, &envelope); err != nil { return JournalAttachment{}, response, fmt.Errorf("client: decode response: %w", err) } return envelope.Attachment, response, nil } // AttachJournalLibraryImage links an existing garden image to an entry. func (c *Client) AttachJournalLibraryImage(ctx context.Context, gardenID, entryID, imageID int) (JournalAttachment, *Response, error) { var envelope struct { Attachment JournalAttachment `json:"attachment"` } response, err := c.do(ctx, http.MethodPost, journalEntryPath(gardenID, entryID)+"/attachments/library", map[string]int{"image_id": imageID}, &envelope) return envelope.Attachment, response, err } // JournalAttachment downloads attachment metadata and binary data. func (c *Client) JournalAttachment(ctx context.Context, gardenID, entryID, attachmentID int) (JournalAttachmentData, *Response, error) { response, data, err := c.doBinary(ctx, http.MethodGet, journalAttachmentPath(gardenID, entryID, attachmentID), nil, "", (25<<20)+1) if err != nil { return JournalAttachmentData{}, response, err } attachment := JournalAttachmentData{Data: data} attachment.ID, attachment.EntryID = attachmentID, entryID attachment.MediaType = strings.Split(response.Header.Get("Content-Type"), ";")[0] if _, params, parseErr := mime.ParseMediaType(response.Header.Get("Content-Disposition")); parseErr == nil { attachment.FileName = params["filename"] } attachment.Size = int64(len(data)) return attachment, response, nil } // DeleteJournalAttachment removes an attachment from an entry. func (c *Client) DeleteJournalAttachment(ctx context.Context, gardenID, entryID, attachmentID int) (*Response, error) { return c.do(ctx, http.MethodDelete, journalAttachmentPath(gardenID, entryID, attachmentID), nil, nil) } func (c *Client) doBinary(ctx context.Context, method, path string, body io.Reader, contentType string, limit int64) (*Response, []byte, error) { relativeURL, err := url.Parse(strings.TrimPrefix(path, "/")) if err != nil { return nil, nil, err } request, err := http.NewRequestWithContext(ctx, method, c.baseURL.ResolveReference(relativeURL).String(), body) if err != nil { return nil, nil, err } request.Header = c.headers.Clone() request.Header.Set("Accept", "application/json, image/*, video/*, audio/*") if contentType != "" { request.Header.Set("Content-Type", contentType) } if c.bearerToken != "" { request.Header.Set("Authorization", "Bearer "+c.bearerToken) } httpResponse, err := c.httpClient.Do(request) if err != nil { return nil, nil, fmt.Errorf("client: execute request: %w", err) } response := &Response{httpResponse} defer httpResponse.Body.Close() data, err := io.ReadAll(io.LimitReader(httpResponse.Body, limit+1)) if err != nil { return response, nil, err } if int64(len(data)) > limit { return response, nil, fmt.Errorf("client: response exceeds %d bytes", limit) } if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 { return response, nil, newAPIError(httpResponse, data) } return response, data, nil }