324 lines
8.4 KiB
Go
324 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client talks to the fid REST API (see https://git.fhi.mpg.de/mike/fid/-/blob/master/API.md).
|
|
type Client struct {
|
|
Server string
|
|
Token string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func NewClient(server, token string) *Client {
|
|
return &Client{
|
|
Server: strings.TrimRight(server, "/"),
|
|
Token: token,
|
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// NewInsecureClient is like NewClient but skips TLS certificate verification.
|
|
// Only meant as a stopgap for hosts with an outdated/incomplete CA trust
|
|
// store (old distros that don't ship the current Let's Encrypt root); the
|
|
// proper fix is updating that host's ca-certificates package.
|
|
func NewInsecureClient(server, token string) *Client {
|
|
c := NewClient(server, token)
|
|
c.HTTP.Transport = &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}
|
|
return c
|
|
}
|
|
|
|
// APIError is returned when the server responds with {"success":0,...}.
|
|
type APIError struct {
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
func (e *APIError) Error() string { return e.Message }
|
|
|
|
func toBool(v interface{}) bool {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t
|
|
case float64:
|
|
return t != 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
// doRequest performs POST /api/v1/<cmd>[/<id>] with body (token is added
|
|
// automatically) and returns the raw response body and HTTP status code,
|
|
// undecoded. call() decodes it into a plain map; Fields() additionally
|
|
// walks the raw bytes to recover key order that a map can't hold.
|
|
func (c *Client) doRequest(cmd, id string, body map[string]interface{}) (raw []byte, status int, err error) {
|
|
if body == nil {
|
|
body = map[string]interface{}{}
|
|
}
|
|
if c.Token != "" {
|
|
body["token"] = c.Token
|
|
}
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
url := c.Server + "/api/v1/" + cmd
|
|
if id != "" {
|
|
url += "/" + id
|
|
}
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("connecting to %s: %w", c.Server, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err = io.ReadAll(resp.Body)
|
|
return raw, resp.StatusCode, err
|
|
}
|
|
|
|
// call performs POST /api/v1/<cmd>[/<id>] with body (token is added automatically) and
|
|
// returns the decoded JSON response. Returns *APIError if the server set success=0.
|
|
func (c *Client) call(cmd, id string, body map[string]interface{}) (map[string]interface{}, error) {
|
|
raw, status, err := c.doRequest(cmd, id, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out map[string]interface{}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("invalid response from server (status %d): %s", status, string(raw))
|
|
}
|
|
if s, ok := out["success"]; !ok || !toBool(s) {
|
|
msg := "request failed"
|
|
if e, ok := out["error"].(string); ok {
|
|
msg = e
|
|
}
|
|
return out, &APIError{Status: status, Message: msg}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) Login(user, password string) (string, error) {
|
|
out, err := c.call("login", "", map[string]interface{}{"user": user, "password": password})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token, _ := out["token"].(string)
|
|
return token, nil
|
|
}
|
|
|
|
func (c *Client) Renew() (string, error) {
|
|
out, err := c.call("renew", "", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token, _ := out["token"].(string)
|
|
return token, nil
|
|
}
|
|
|
|
func (c *Client) List() ([]interface{}, error) {
|
|
out, err := c.call("list", "", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids, _ := out["ids"].([]interface{})
|
|
return ids, nil
|
|
}
|
|
|
|
// ListColumns returns partial device records (id plus the given columns
|
|
// only) instead of bare ids - much cheaper than Get() for building a table
|
|
// over every device.
|
|
func (c *Client) ListColumns(columns []string) ([]interface{}, error) {
|
|
cols := make([]interface{}, len(columns))
|
|
for i, s := range columns {
|
|
cols[i] = s
|
|
}
|
|
out, err := c.call("list", "", map[string]interface{}{"columns": cols})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
devices, _ := out["devices"].([]interface{})
|
|
return devices, nil
|
|
}
|
|
|
|
func (c *Client) Get(ids []string) ([]interface{}, error) {
|
|
out, err := c.call("get", strings.Join(ids, ","), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
devices, _ := out["devices"].([]interface{})
|
|
return devices, nil
|
|
}
|
|
|
|
func (c *Client) Search(key string) ([]interface{}, error) {
|
|
out, err := c.call("search", "", map[string]interface{}{"key": key})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids, _ := out["ids"].([]interface{})
|
|
return ids, nil
|
|
}
|
|
|
|
// SearchColumns is Search but returns partial device records (id plus the
|
|
// given columns only), same idea as ListColumns.
|
|
func (c *Client) SearchColumns(key string, columns []string) ([]interface{}, error) {
|
|
cols := make([]interface{}, len(columns))
|
|
for i, s := range columns {
|
|
cols[i] = s
|
|
}
|
|
out, err := c.call("search", "", map[string]interface{}{"key": key, "columns": cols})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
devices, _ := out["devices"].([]interface{})
|
|
return devices, nil
|
|
}
|
|
|
|
func (c *Client) Types() ([]interface{}, error) {
|
|
out, err := c.call("types", "", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v, _ := out["types"].([]interface{})
|
|
return v, nil
|
|
}
|
|
|
|
func (c *Client) Departments() ([]interface{}, error) {
|
|
out, err := c.call("departments", "", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v, _ := out["departments"].([]interface{})
|
|
return v, nil
|
|
}
|
|
|
|
func (c *Client) Vlans() ([]interface{}, error) {
|
|
out, err := c.call("vlans", "", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v, _ := out["vlans"].([]interface{})
|
|
return v, nil
|
|
}
|
|
|
|
// Fields returns field metadata together with the field keys in the order
|
|
// the server sent them - the DB column order, which is also the order the
|
|
// web UI's edit form lays fields out in (see its `SHOW FULL COLUMNS FROM
|
|
// device`). Go's map[string]interface{} can't hold that order, so it's
|
|
// recovered separately from the raw response via objectKeyOrder.
|
|
func (c *Client) Fields() (fields map[string]interface{}, order []string, err error) {
|
|
raw, status, err := c.doRequest("fields", "", nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var out map[string]interface{}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, nil, fmt.Errorf("invalid response from server (status %d): %s", status, string(raw))
|
|
}
|
|
if s, ok := out["success"]; !ok || !toBool(s) {
|
|
msg := "request failed"
|
|
if e, ok := out["error"].(string); ok {
|
|
msg = e
|
|
}
|
|
return nil, nil, &APIError{Status: status, Message: msg}
|
|
}
|
|
|
|
fields, _ = out["fields"].(map[string]interface{})
|
|
order, orderErr := objectKeyOrder(raw, "fields")
|
|
if orderErr != nil {
|
|
order = sortedKeys(fields) // cosmetic only - don't fail the command over it
|
|
}
|
|
return fields, order, nil
|
|
}
|
|
|
|
// objectKeyOrder returns the key order of the nested JSON object found at
|
|
// key within raw (a JSON object), by walking the raw bytes with a streaming
|
|
// decoder instead of going through a map.
|
|
func objectKeyOrder(raw []byte, key string) ([]string, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
if err := expectDelim(dec, '{'); err != nil {
|
|
return nil, err
|
|
}
|
|
for dec.More() {
|
|
t, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if k, _ := t.(string); k != key {
|
|
var discard interface{}
|
|
if err := dec.Decode(&discard); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := expectDelim(dec, '{'); err != nil {
|
|
return nil, err
|
|
}
|
|
var order []string
|
|
for dec.More() {
|
|
kt, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ks, _ := kt.(string)
|
|
order = append(order, ks)
|
|
var discard interface{}
|
|
if err := dec.Decode(&discard); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return order, nil
|
|
}
|
|
return nil, fmt.Errorf("key %q not found in response", key)
|
|
}
|
|
|
|
func expectDelim(dec *json.Decoder, want json.Delim) error {
|
|
t, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d, ok := t.(json.Delim); !ok || d != want {
|
|
return fmt.Errorf("unexpected token %v, want %q", t, want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) New(fields map[string]interface{}) (string, error) {
|
|
out, err := c.call("new", "", fields)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%v", out["id"]), nil
|
|
}
|
|
|
|
func (c *Client) Edit(id string, fields map[string]interface{}) error {
|
|
_, err := c.call("edit", id, fields)
|
|
return err
|
|
}
|
|
|
|
func (c *Client) Delete(id string) error {
|
|
_, err := c.call("delete", id, nil)
|
|
return err
|
|
}
|
|
|
|
func (c *Client) Recover(id string) error {
|
|
_, err := c.call("recover", id, nil)
|
|
return err
|
|
}
|