This method supports doing "errors.Is" checks so downstream can handle API errors more specifically. In my case, since I copied your lazyClient example, but I'm using it in a server, the Login needs to be refreshed if an API call errors out with "api.err.LoginRequired". There was no clean way to check for this specific error in order to refresh the login and retry.
306 lines
6.6 KiB
Go
306 lines
6.6 KiB
Go
package unifi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const (
|
|
apiPath = "/api"
|
|
apiV2Path = "/v2/api"
|
|
|
|
apiPathNew = "/proxy/network/api"
|
|
apiV2PathNew = "/proxy/network/v2/api"
|
|
|
|
loginPath = "/api/login"
|
|
loginPathNew = "/api/auth/login"
|
|
|
|
statusPath = "/status"
|
|
statusPathNew = "/proxy/network/status"
|
|
)
|
|
|
|
type NotFoundError struct{}
|
|
|
|
func (err *NotFoundError) Error() string {
|
|
return "not found"
|
|
}
|
|
|
|
type APIError struct {
|
|
RC string
|
|
Message string
|
|
}
|
|
|
|
func (err *APIError) Error() string {
|
|
return err.Message
|
|
}
|
|
|
|
func (err *APIError) Is(target error) bool {
|
|
var apiError *APIError
|
|
if errors.As(target, &apiError) {
|
|
if err.RC == apiError.RC && err.Message == apiError.Message {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type Client struct {
|
|
// single thread client calls for CSRF, etc.
|
|
sync.Mutex
|
|
|
|
c *http.Client
|
|
baseURL *url.URL
|
|
|
|
apiPath string
|
|
apiV2Path string
|
|
loginPath string
|
|
statusPath string
|
|
|
|
csrf string
|
|
|
|
version string
|
|
}
|
|
|
|
func (c *Client) CSRFToken() string {
|
|
return c.csrf
|
|
}
|
|
|
|
func (c *Client) Version() string {
|
|
return c.version
|
|
}
|
|
|
|
func (c *Client) SetBaseURL(base string) error {
|
|
var err error
|
|
c.baseURL, err = url.Parse(base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// error for people who are still passing hard coded old paths
|
|
if path := strings.TrimSuffix(c.baseURL.Path, "/"); path == apiPath {
|
|
return fmt.Errorf("expected a base URL without the `/api`, got: %q", c.baseURL)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) SetHTTPClient(hc *http.Client) error {
|
|
c.c = hc
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) setAPIUrlStyle(ctx context.Context) error {
|
|
// check if new style API
|
|
// this is modified from the unifi-poller (https://github.com/unifi-poller/unifi) implementation.
|
|
// see https://github.com/unifi-poller/unifi/blob/4dc44f11f61a2e08bf7ec5b20c71d5bced837b5d/unifi.go#L101-L104
|
|
// and https://github.com/unifi-poller/unifi/commit/43a6b225031a28f2b358f52d03a7217c7b524143
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.String(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// We can't share these cookies with other requests, so make a new client.
|
|
// Checking the return code on the first request so don't follow a redirect.
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
Transport: c.c.Transport,
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
// the new API returns a 200 for a / request
|
|
c.apiPath = apiPathNew
|
|
c.apiV2Path = apiV2PathNew
|
|
c.loginPath = loginPathNew
|
|
c.statusPath = statusPathNew
|
|
return nil
|
|
}
|
|
|
|
// The old version returns a "302" (to /manage) for a / request
|
|
c.apiPath = apiPath
|
|
c.apiV2Path = apiV2Path
|
|
c.loginPath = loginPath
|
|
c.statusPath = statusPath
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) Login(ctx context.Context, user, pass string) error {
|
|
if c.c == nil {
|
|
c.c = &http.Client{}
|
|
|
|
jar, _ := cookiejar.New(nil)
|
|
c.c.Jar = jar
|
|
}
|
|
|
|
err := c.setAPIUrlStyle(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to determine API URL style: %w", err)
|
|
}
|
|
|
|
var status struct {
|
|
Meta struct {
|
|
ServerVersion string `json:"server_version"`
|
|
UUID string `json:"uuid"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
err = c.do(ctx, "POST", c.loginPath, &struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}{
|
|
Username: user,
|
|
Password: pass,
|
|
}, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = c.do(ctx, "GET", c.statusPath, nil, &status)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if version := status.Meta.ServerVersion; version != "" {
|
|
c.version = status.Meta.ServerVersion
|
|
return nil
|
|
}
|
|
|
|
// newer version of 6.0 controller, use sysinfo to determine version
|
|
// using default site since it must exist
|
|
si, err := c.sysinfo(ctx, "default")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.version = si.Version
|
|
|
|
if c.version == "" {
|
|
return errors.New("unable to determine controller version")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, relativeURL string, reqBody interface{}, respBody interface{}) error {
|
|
// single threading requests, this is mostly to assist in CSRF token propagation
|
|
c.Lock()
|
|
defer c.Unlock()
|
|
|
|
var (
|
|
reqReader io.Reader
|
|
err error
|
|
reqBytes []byte
|
|
)
|
|
if reqBody != nil {
|
|
reqBytes, err = json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to marshal JSON: %s %s %w", method, relativeURL, err)
|
|
}
|
|
reqReader = bytes.NewReader(reqBytes)
|
|
}
|
|
|
|
reqURL, err := url.Parse(relativeURL)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to parse URL: %s %s %w", method, relativeURL, err)
|
|
}
|
|
if !strings.HasPrefix(relativeURL, "/") && !reqURL.IsAbs() {
|
|
reqURL.Path = path.Join(c.apiPath, reqURL.Path)
|
|
}
|
|
|
|
url := c.baseURL.ResolveReference(reqURL)
|
|
req, err := http.NewRequestWithContext(ctx, method, url.String(), reqReader)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create request: %s %s %w", method, relativeURL, err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", "terraform-provider-unifi/0.1")
|
|
req.Header.Add("Content-Type", "application/json; charset=utf-8")
|
|
|
|
if c.csrf != "" {
|
|
req.Header.Set("X-Csrf-Token", c.csrf)
|
|
}
|
|
|
|
resp, err := c.c.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to perform request: %s %s %w", method, relativeURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return &NotFoundError{}
|
|
}
|
|
|
|
if csrf := resp.Header.Get("X-Csrf-Token"); csrf != "" {
|
|
c.csrf = resp.Header.Get("X-Csrf-Token")
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
errBody := struct {
|
|
Meta meta `json:"meta"`
|
|
Data []struct {
|
|
Meta meta `json:"meta"`
|
|
} `json:"data"`
|
|
}{}
|
|
if err = json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
|
|
return err
|
|
}
|
|
var apiErr error
|
|
if len(errBody.Data) > 0 && errBody.Data[0].Meta.RC == "error" {
|
|
// check first error in data, should we look for more than one?
|
|
apiErr = errBody.Data[0].Meta.error()
|
|
}
|
|
if apiErr == nil {
|
|
apiErr = errBody.Meta.error()
|
|
}
|
|
return fmt.Errorf("%w (%s) for %s %s", apiErr, resp.Status, method, url.String())
|
|
}
|
|
|
|
if respBody == nil || resp.ContentLength == 0 {
|
|
return nil
|
|
}
|
|
|
|
// TODO: check rc in addition to status code?
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(respBody)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to decode body: %s %s %w", method, relativeURL, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type meta struct {
|
|
RC string `json:"rc"`
|
|
Message string `json:"msg"`
|
|
}
|
|
|
|
func (m *meta) error() error {
|
|
if m.RC != "ok" {
|
|
return &APIError{
|
|
RC: m.RC,
|
|
Message: m.Message,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|