Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 25s
Tests / Test (Go stable) (pull_request) Successful in 27s
430 lines
13 KiB
Go
430 lines
13 KiB
Go
package onlyoffice
|
|
|
|
// Transport-layer helpers used by the untyped domain methods (CRM, calendar,
|
|
// tasks, files). Authentication lives in auth.go; the typed Request/Query
|
|
// abstraction lives in request.go. These helpers deliberately share the
|
|
// `*Client` state with the typed path so token refresh, base URL, and
|
|
// self-id caching are handled once.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// baseURL returns the configured base URL without trailing slash.
|
|
func (c *Client) baseURL() string {
|
|
return strings.TrimRight(c.credentials.Url, "/")
|
|
}
|
|
|
|
// truncate crops s to n runes, appending "..." when truncated. Used in error
|
|
// messages to keep OnlyOffice HTML payloads readable.
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
// responseField extracts a top-level JSON key (for example "response") from a
|
|
// raw OnlyOffice envelope. Returns an error when the key is missing.
|
|
func responseField(raw json.RawMessage, key string) (json.RawMessage, error) {
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
v, ok := m[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("response missing %q", key)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// ResponseArray executes a GET and returns the "response" field as []map.
|
|
// Returns (nil, nil) when the field is JSON null.
|
|
func (c *Client) ResponseArray(ctx context.Context, path string) ([]map[string]any, error) {
|
|
raw, err := c.getJSON(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if string(resp) == "null" {
|
|
return nil, nil
|
|
}
|
|
var list []map[string]any
|
|
if err := json.Unmarshal(resp, &list); err != nil {
|
|
return nil, err
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// ResponseObject executes a GET and decodes the "response" field into a map.
|
|
// Returns (nil, nil) when the field is JSON null or absent. Companion to
|
|
// ResponseArray — factored out to eliminate the ~15 identical decode blocks
|
|
// in crm.go / tasks.go / calendar.go.
|
|
func (c *Client) ResponseObject(ctx context.Context, path string) (map[string]any, error) {
|
|
raw, err := c.getJSON(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unmarshalResponseObject(raw)
|
|
}
|
|
|
|
// postFormObject issues an authenticated POST (form-encoded) and decodes the
|
|
// "response" field into a map.
|
|
func (c *Client) postFormObject(ctx context.Context, path string, fields url.Values) (map[string]any, error) {
|
|
raw, err := c.postForm(ctx, path, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unmarshalResponseObject(raw)
|
|
}
|
|
|
|
// putFormObject issues an authenticated PUT (form-encoded) and decodes the
|
|
// "response" field into a map.
|
|
func (c *Client) putFormObject(ctx context.Context, path string, fields url.Values) (map[string]any, error) {
|
|
raw, err := c.putForm(ctx, path, fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unmarshalResponseObject(raw)
|
|
}
|
|
|
|
// deleteObject issues an authenticated DELETE and decodes the "response"
|
|
// field into a map.
|
|
func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any, error) {
|
|
raw, err := c.deleteReq(ctx, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unmarshalResponseObject(raw)
|
|
}
|
|
|
|
// unmarshalResponseObject extracts the "response" field from a raw OnlyOffice
|
|
// envelope and decodes it into map[string]any. Returns (nil, nil) for a null
|
|
// response, an empty array, or scalar payloads. When the API returns a list
|
|
// (for example PUT /people/status/...), the first element is returned.
|
|
func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
|
|
resp, err := responseField(raw, "response")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(resp) == 0 || string(resp) == "null" {
|
|
return nil, nil
|
|
}
|
|
switch resp[0] {
|
|
case '{':
|
|
var out map[string]any
|
|
if err := json.Unmarshal(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
case '[':
|
|
var list []map[string]any
|
|
if err := json.Unmarshal(resp, &list); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(list) == 0 {
|
|
return nil, nil
|
|
}
|
|
return list[0], nil
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// getJSON issues an authenticated GET and returns the raw response body,
|
|
// retrying transient answers (see retryRaw).
|
|
func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) {
|
|
return retryRaw(ctx, func() (json.RawMessage, error) { return c.getJSONOnce(ctx, path) })
|
|
}
|
|
|
|
func (c *Client) getJSONOnce(ctx context.Context, path string) (json.RawMessage, error) {
|
|
auth, err := c.authHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, statusError(resp.StatusCode, retryAfterOf(resp), "GET %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// postForm issues an authenticated POST with application/x-www-form-urlencoded body.
|
|
func (c *Client) postForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
|
|
return c.formRequest(ctx, http.MethodPost, path, fields)
|
|
}
|
|
|
|
// putForm issues an authenticated PUT with application/x-www-form-urlencoded body.
|
|
func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
|
|
return c.formRequest(ctx, http.MethodPut, path, fields)
|
|
}
|
|
|
|
// deleteForm issues an authenticated DELETE with application/x-www-form-urlencoded body.
|
|
func (c *Client) deleteForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
|
|
return c.formRequest(ctx, http.MethodDelete, path, fields)
|
|
}
|
|
|
|
func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
|
|
return retryRaw(ctx, func() (json.RawMessage, error) {
|
|
return c.formRequestOnce(ctx, method, path, fields)
|
|
})
|
|
}
|
|
|
|
func (c *Client) formRequestOnce(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
|
|
auth, err := c.authHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, strings.NewReader(fields.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, statusError(resp.StatusCode, retryAfterOf(resp), "%s form %s: %d %s", method, path, resp.StatusCode, truncate(string(raw), 400))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// deleteReq issues an authenticated DELETE.
|
|
func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, error) {
|
|
return retryRaw(ctx, func() (json.RawMessage, error) { return c.deleteReqOnce(ctx, path) })
|
|
}
|
|
|
|
func (c *Client) deleteReqOnce(ctx context.Context, path string) (json.RawMessage, error) {
|
|
auth, err := c.authHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, statusError(resp.StatusCode, retryAfterOf(resp), "DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// putJSONObject issues an authenticated PUT with JSON body and decodes response.
|
|
func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
|
|
raw, err := c.putJSON(ctx, path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unmarshalResponseObject(raw)
|
|
}
|
|
|
|
// postJSONObject issues an authenticated POST with JSON body and decodes response.
|
|
func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
|
|
raw, err := c.postJSON(ctx, path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unmarshalResponseObject(raw)
|
|
}
|
|
|
|
// postJSON issues an authenticated POST with application/json body.
|
|
func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
|
return retryRaw(ctx, func() (json.RawMessage, error) { return c.postJSONOnce(ctx, path, body) })
|
|
}
|
|
|
|
func (c *Client) postJSONOnce(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
|
auth, err := c.authHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rdr io.Reader
|
|
switch b := body.(type) {
|
|
case nil:
|
|
rdr = strings.NewReader("{}")
|
|
case []byte:
|
|
rdr = bytes.NewReader(b)
|
|
case string:
|
|
rdr = strings.NewReader(b)
|
|
default:
|
|
buf, err := json.Marshal(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rdr = bytes.NewReader(buf)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, statusError(resp.StatusCode, retryAfterOf(resp), "POST JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// putJSON issues an authenticated PUT with application/json body.
|
|
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
|
return retryRaw(ctx, func() (json.RawMessage, error) { return c.putJSONOnce(ctx, path, body) })
|
|
}
|
|
|
|
func (c *Client) putJSONOnce(ctx context.Context, path string, body any) (json.RawMessage, error) {
|
|
auth, err := c.authHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rdr io.Reader
|
|
switch b := body.(type) {
|
|
case nil:
|
|
rdr = strings.NewReader("{}")
|
|
case []byte:
|
|
rdr = bytes.NewReader(b)
|
|
case string:
|
|
rdr = strings.NewReader(b)
|
|
default:
|
|
buf, err := json.Marshal(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rdr = bytes.NewReader(buf)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL()+path, rdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, statusError(resp.StatusCode, retryAfterOf(resp), "PUT JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// uploadMultipart posts a single file to path under the given form field name.
|
|
func (c *Client) uploadMultipart(ctx context.Context, path, fieldName, filePath string) (json.RawMessage, error) {
|
|
return c.uploadMultipartMethod(ctx, http.MethodPost, path, fieldName, filePath)
|
|
}
|
|
|
|
// uploadMultipartMethod sends a single-file multipart request with the given
|
|
// HTTP method. The OnlyOffice Documents API needs PUT for /update (a new
|
|
// version) and POST for /upload (a new file); sending POST to /update answers
|
|
// 500 on current servers. The file is re-opened per attempt, so transient
|
|
// answers are retried like every other request.
|
|
func (c *Client) uploadMultipartMethod(ctx context.Context, method, path, fieldName, filePath string) (json.RawMessage, error) {
|
|
return retryRaw(ctx, func() (json.RawMessage, error) {
|
|
return c.uploadMultipartOnce(ctx, method, path, fieldName, filePath)
|
|
})
|
|
}
|
|
|
|
func (c *Client) uploadMultipartOnce(ctx context.Context, method, path, fieldName, filePath string) (json.RawMessage, error) {
|
|
auth, err := c.authHeader()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, err := mw.CreateFormFile(fieldName, filepath.Base(filePath))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := io.Copy(part, f); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, &buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", auth)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, statusError(resp.StatusCode, retryAfterOf(resp), "upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
|
}
|
|
return raw, nil
|
|
}
|