Files

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
}