feat: v0 — glpi-go client, mglpi CLI, mglpi-mcp, fake-GLPI test suite [#767]
https://projects.knownelement.com/issues/767#note-4191
This commit is contained in:
+251
@@ -0,0 +1,251 @@
|
||||
package glpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Change status constants (GLPI change lifecycle).
|
||||
const (
|
||||
StatusNew = 1
|
||||
StatusEvaluation = 2
|
||||
StatusApproval = 3
|
||||
StatusTest = 4
|
||||
StatusQualification = 5
|
||||
StatusWaiting = 6
|
||||
StatusAccepted = 7
|
||||
StatusAssigned = 8
|
||||
StatusPlanned = 9
|
||||
StatusPending = 10
|
||||
StatusSolved = 11
|
||||
StatusClosed = 12
|
||||
)
|
||||
|
||||
// statusNames maps status ids to human names (CLI display + name-based
|
||||
// transitions; GLPI has no enumeration endpoint for these).
|
||||
var statusNames = map[int]string{
|
||||
StatusNew: "new",
|
||||
StatusEvaluation: "evaluation",
|
||||
StatusApproval: "approval",
|
||||
StatusTest: "test",
|
||||
StatusQualification: "qualification",
|
||||
StatusWaiting: "waiting",
|
||||
StatusAccepted: "accepted",
|
||||
StatusAssigned: "assigned",
|
||||
StatusPlanned: "planned",
|
||||
StatusPending: "pending",
|
||||
StatusSolved: "solved",
|
||||
StatusClosed: "closed",
|
||||
}
|
||||
|
||||
// StatusName renders a status id ("new", "solved", ...) or "status N".
|
||||
func StatusName(id int) string {
|
||||
if n, ok := statusNames[id]; ok {
|
||||
return n
|
||||
}
|
||||
return fmt.Sprintf("status %d", id)
|
||||
}
|
||||
|
||||
// StatusID resolves a status name case-insensitively ("Solved" -> 11).
|
||||
func StatusID(name string) (int, bool) {
|
||||
for id, n := range statusNames {
|
||||
if eqfold(n, name) {
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Change is GLPI's read shape for one change (GET /change/<id>).
|
||||
type Change struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Status int `json:"status"`
|
||||
Urgency int `json:"urgency"`
|
||||
Impact int `json:"impact"`
|
||||
Date string `json:"date"`
|
||||
DateMod string `json:"date_mod"`
|
||||
}
|
||||
|
||||
// ChangeRow is one row of a change search (fields 1 name, 2 id,
|
||||
// 12 status).
|
||||
type ChangeRow struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// CreateChange creates a change (POST /change/) and returns its id.
|
||||
//
|
||||
// GLPI contract modeled faithfully: the payload is {"input":{...}} — an
|
||||
// OBJECT (the ITILFollowup endpoint is the array-flavored one) — and
|
||||
// the reply is an ARRAY: [{"id":N,"message":"..."}]. urgency and impact
|
||||
// are GLPI's 1..5 scales (3 = medium).
|
||||
func (c *Client) CreateChange(ctx context.Context, name, content string, urgency, impact int) (int, error) {
|
||||
if name == "" {
|
||||
return 0, fmt.Errorf("%w: change name required", ErrValidation)
|
||||
}
|
||||
if urgency < 1 || urgency > 5 || impact < 1 || impact > 5 {
|
||||
return 0, fmt.Errorf("%w: urgency/impact must be 1..5", ErrValidation)
|
||||
}
|
||||
input := map[string]any{
|
||||
"name": name,
|
||||
"content": content,
|
||||
}
|
||||
if urgency != 0 {
|
||||
input["urgency"] = urgency
|
||||
}
|
||||
if impact != 0 {
|
||||
input["impact"] = impact
|
||||
}
|
||||
ids, err := c.postCreate(ctx, "/change", map[string]any{"input": input})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 || ids[0] == 0 {
|
||||
return 0, fmt.Errorf("%w: create returned no id", ErrMalformedResponse)
|
||||
}
|
||||
return ids[0], nil
|
||||
}
|
||||
|
||||
// postCreate sends a create payload and parses GLPI's ARRAY reply
|
||||
// [{"id":N,"message":"..."}] into ids.
|
||||
func (c *Client) postCreate(ctx context.Context, path string, payload any) ([]int, error) {
|
||||
var arr []struct {
|
||||
ID int `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.call(ctx, http.MethodPost, path, nil, payload, &arr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int, 0, len(arr))
|
||||
for _, a := range arr {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// GetChange fetches one change (GET /change/<id>).
|
||||
func (c *Client) GetChange(ctx context.Context, id int) (*Change, error) {
|
||||
var ch Change
|
||||
if err := c.call(ctx, http.MethodGet, "/change/"+itoa(id), nil, nil, &ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
// ListChanges searches changes (GET /search/Change/) with forcedisplay
|
||||
// fields 1 (name), 2 (id), 12 (status). GLPI returns rows as OBJECTS
|
||||
// KEYED BY FIELD-ID STRING — parsed here into ChangeRow. status 0 lists
|
||||
// all; any other value filters field 12 with an equals criterion.
|
||||
func (c *Client) ListChanges(ctx context.Context, status int) ([]ChangeRow, error) {
|
||||
q := url.Values{}
|
||||
forced := []string{"1", "2", "12"}
|
||||
for _, f := range forced {
|
||||
q.Add("forcedisplay[]", f)
|
||||
}
|
||||
if status > 0 {
|
||||
crit, err := json.Marshal([]map[string]any{{
|
||||
"field": 12, "searchtype": "equals", "value": status,
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: cannot encode criteria", ErrMalformedResponse)
|
||||
}
|
||||
q.Set("criteria", string(crit))
|
||||
}
|
||||
var body struct {
|
||||
Total int `json:"totalcount"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := c.call(ctx, http.MethodGet, "/search/Change", q, nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]ChangeRow, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
rows = append(rows, ChangeRow{
|
||||
Name: asString(d["1"]),
|
||||
ID: asInt(d["2"]),
|
||||
Status: asInt(d["12"]),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// TransitionChange moves a change to a status (PUT /change/<id> with
|
||||
// {"input":{"status":N}}). GLPI answers 200 with an array whose members
|
||||
// carry a boolean result — a false anywhere is a failed update.
|
||||
func (c *Client) TransitionChange(ctx context.Context, id, status int) error {
|
||||
if status < StatusNew || status > StatusClosed {
|
||||
return fmt.Errorf("%w: unknown change status %d", ErrValidation, status)
|
||||
}
|
||||
payload := map[string]any{"input": map[string]any{"status": status}}
|
||||
var arr []map[string]any
|
||||
if err := c.call(ctx, http.MethodPut, "/change/"+itoa(id), nil, payload, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range arr {
|
||||
for _, v := range m {
|
||||
if b, ok := v.(bool); ok && !b {
|
||||
return fmt.Errorf("%w: transition rejected", ErrValidation)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddFollowup appends a followup note to a change (POST /ITILFollowup).
|
||||
//
|
||||
// GLPI contract modeled faithfully: this endpoint REQUIRES the input to
|
||||
// be an ARRAY of objects — {"input":[{...}]} — where the Change-create
|
||||
// endpoint takes the object form.
|
||||
func (c *Client) AddFollowup(ctx context.Context, changeID int, content string) error {
|
||||
if content == "" {
|
||||
return fmt.Errorf("%w: followup content required", ErrValidation)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"input": []map[string]any{{
|
||||
"itemtype": "Change",
|
||||
"items_id": changeID,
|
||||
"content": content,
|
||||
}},
|
||||
}
|
||||
_, err := c.postCreate(ctx, "/ITILFollowup", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// asString coerces a search-row cell (string or number) to string.
|
||||
func asString(v any) string {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case float64:
|
||||
return trimFloat(s)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// asInt coerces a search-row cell (number or numeric string) to int.
|
||||
// GLPI search cells arrive as JSON numbers or strings depending on the
|
||||
// field; both must parse.
|
||||
func asInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case string:
|
||||
i, _ := parseLeadingInt(n)
|
||||
return i
|
||||
case nil:
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
// Package glpi is a stdlib-only client for the GLPI REST API
|
||||
// (apirest.php). It exists so the harness and every vertical talk to
|
||||
// the ITSM/CMDB through one Go library instead of python glue: change
|
||||
// create/list/show/transition, ITIL followups, CI search, and the
|
||||
// session/profile lifecycle.
|
||||
//
|
||||
// Security discipline: authentication travels ONLY in headers —
|
||||
// App-Token on every request, Authorization: user_token on initSession,
|
||||
// Session-Token after it. Tokens never appear in flags, logs, or error
|
||||
// strings; response bodies are never surfaced in error messages (a
|
||||
// server echo is assumed to be able to carry a token), so errors are
|
||||
// one-line, parseable "sentinel: http NNN" shapes.
|
||||
//
|
||||
// GLPI quirks modeled faithfully:
|
||||
// - POST create endpoints return an ARRAY: [{"id":N,"message":"..."}].
|
||||
// - Change create takes {"input":{...}}; ITILFollowup REQUIRES
|
||||
// {"input":[{...}]}.
|
||||
// - search rows are objects keyed by field-id string when forcedisplay
|
||||
// is used ({"1":"name","2":7,"12":3}).
|
||||
// - initSession -> {"session_token":"..."}; changeActiveProfile
|
||||
// switches the session's active profile (agent mode).
|
||||
package glpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors. Wrap-check with errors.Is; every API failure maps to
|
||||
// exactly one of these plus an "http NNN" code in the message.
|
||||
var (
|
||||
ErrUnreachable = errors.New("glpi: unreachable")
|
||||
ErrMalformedResponse = errors.New("glpi: malformed response")
|
||||
ErrAuth = errors.New("glpi: auth failed")
|
||||
ErrNotFound = errors.New("glpi: not found")
|
||||
ErrValidation = errors.New("glpi: validation failed")
|
||||
ErrServer = errors.New("glpi: server error")
|
||||
)
|
||||
|
||||
// Config configures a Client.
|
||||
type Config struct {
|
||||
BaseURL string // full API endpoint, e.g. https://cmdb.knownelement.com/apirest.php
|
||||
AppToken string // GLPI App-Token; header on every request, never logged
|
||||
UserToken string // GLPI user token; Authorization header at initSession, never logged
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client is a GLPI REST client. It lazily opens a session on the first
|
||||
// call (InitSession) and re-opens one after KillSession. Safe for
|
||||
// concurrent use.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
session string
|
||||
}
|
||||
|
||||
// New builds a client. A zero Timeout means 30s. No network traffic
|
||||
// happens until the first call.
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 30 * time.Second
|
||||
}
|
||||
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
|
||||
}
|
||||
|
||||
// sessionToken returns the live session token, if any.
|
||||
func (c *Client) sessionToken() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.session
|
||||
}
|
||||
|
||||
// setSession stores the session token obtained from initSession.
|
||||
func (c *Client) setSession(tok string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.session = tok
|
||||
}
|
||||
|
||||
// InitSession opens a GLPI session (POST /initSession). Called
|
||||
// automatically on the first API call; public so callers can pre-warm
|
||||
// and so the profile switch has a session to act on. Idempotent while
|
||||
// the session is live.
|
||||
func (c *Client) InitSession(ctx context.Context) error {
|
||||
if tok := c.sessionToken(); tok != "" {
|
||||
return nil
|
||||
}
|
||||
var resp struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
if err := c.raw(ctx, http.MethodPost, "/initSession", nil, nil, &resp, true); err != nil {
|
||||
if errors.Is(err, ErrValidation) {
|
||||
// GLPI answers 400 (ERROR_GLPI_LOGIN / ERROR_APP_TOKEN_...)
|
||||
// for bad credentials or a wrong app token — that is an
|
||||
// auth failure, not a validation failure.
|
||||
return fmt.Errorf("%w: http 400 (initSession refused)", ErrAuth)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if resp.SessionToken == "" {
|
||||
return fmt.Errorf("%w: initSession returned no session_token", ErrMalformedResponse)
|
||||
}
|
||||
c.setSession(resp.SessionToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
// KillSession closes the session server-side (GET /killSession) and
|
||||
// forgets the local token; the next call transparently re-inits.
|
||||
func (c *Client) KillSession(ctx context.Context) error {
|
||||
if err := c.call(ctx, http.MethodGet, "/killSession", nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
c.setSession("")
|
||||
return nil
|
||||
}
|
||||
|
||||
// call performs one authenticated exchange: it ensures a live session,
|
||||
// then sends the request with Session-Token auth.
|
||||
func (c *Client) call(ctx context.Context, method, path string, query url.Values, in, out any) error {
|
||||
if c.sessionToken() == "" {
|
||||
if err := c.InitSession(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return c.raw(ctx, method, path, query, in, out, false)
|
||||
}
|
||||
|
||||
// raw performs one JSON exchange. initAuth selects the initSession auth
|
||||
// flavor (Authorization: user_token) instead of Session-Token. out may
|
||||
// be nil for bodies the caller does not parse (killSession answers
|
||||
// `true`, update answers a success array checked separately).
|
||||
func (c *Client) raw(ctx context.Context, method, path string, query url.Values, in, out any, initAuth bool) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: cannot encode request", ErrMalformedResponse)
|
||||
}
|
||||
body = strings.NewReader(string(b))
|
||||
}
|
||||
u := c.cfg.BaseURL + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
|
||||
}
|
||||
// Tokens live in headers and only in headers.
|
||||
req.Header.Set("App-Token", c.cfg.AppToken)
|
||||
if initAuth {
|
||||
req.Header.Set("Authorization", "user_token "+c.cfg.UserToken)
|
||||
} else if tok := c.sessionToken(); tok != "" {
|
||||
req.Header.Set("Session-Token", tok)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// Transport errors embed URLs and peer text; drop them all.
|
||||
return ErrUnreachable
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return ErrUnreachable
|
||||
}
|
||||
return handleResponse(resp.StatusCode, raw, out)
|
||||
}
|
||||
|
||||
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
|
||||
// are never surfaced: a server echo is assumed to be able to contain a
|
||||
// token (see the fakeglpi package, which deliberately echoes it).
|
||||
func handleResponse(status int, body []byte, out any) error {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("%w: empty body", ErrMalformedResponse)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return statusError(status)
|
||||
}
|
||||
}
|
||||
|
||||
// statusError maps a non-2xx status to a sentinel + parseable one-liner.
|
||||
func statusError(status int) error {
|
||||
switch {
|
||||
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
||||
case status == http.StatusNotFound:
|
||||
return fmt.Errorf("%w: http %d", ErrNotFound, status)
|
||||
case status == http.StatusUnprocessableEntity || status == http.StatusBadRequest || status == http.StatusConflict:
|
||||
return fmt.Errorf("%w: http %d", ErrValidation, status)
|
||||
default:
|
||||
return fmt.Errorf("%w: http %d", ErrServer, status)
|
||||
}
|
||||
}
|
||||
|
||||
// itoa is a tiny local alias used across the endpoint files.
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
@@ -0,0 +1,82 @@
|
||||
package glpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchRow is one CI hit from a search: the well-known fields 2 (id)
|
||||
// and 1 (name) plus the raw row keyed by field-id string, exactly as
|
||||
// GLPI returns it under forcedisplay.
|
||||
type SearchRow struct {
|
||||
ID int
|
||||
Name string
|
||||
Fields map[string]any
|
||||
}
|
||||
|
||||
// SearchCI searches any itemtype's CIs (GET /search/<itemtype>) with a
|
||||
// contains criterion on field 1 (name) and forcedisplay 1,2.
|
||||
func (c *Client) SearchCI(ctx context.Context, itemtype, term string) ([]SearchRow, error) {
|
||||
if itemtype == "" {
|
||||
return nil, fmt.Errorf("%w: itemtype required", ErrValidation)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Add("forcedisplay[]", "1")
|
||||
q.Add("forcedisplay[]", "2")
|
||||
if term != "" {
|
||||
crit, err := json.Marshal([]map[string]any{{
|
||||
"field": 1, "searchtype": "contains", "value": term,
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: cannot encode criteria", ErrMalformedResponse)
|
||||
}
|
||||
q.Set("criteria", string(crit))
|
||||
}
|
||||
var body struct {
|
||||
Total int `json:"totalcount"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := c.call(ctx, http.MethodGet, "/search/"+esc(itemtype), q, nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]SearchRow, 0, len(body.Data))
|
||||
for _, d := range body.Data {
|
||||
rows = append(rows, SearchRow{
|
||||
Name: asString(d["1"]),
|
||||
ID: asInt(d["2"]),
|
||||
Fields: d,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetItem fetches one CI of any itemtype raw (GET /<itemtype>/<id>),
|
||||
// used by `ci show`. The full server object is returned untouched.
|
||||
func (c *Client) GetItem(ctx context.Context, itemtype string, id int) (map[string]any, error) {
|
||||
var obj map[string]any
|
||||
if err := c.call(ctx, http.MethodGet, "/"+esc(itemtype)+"/"+itoa(id), nil, nil, &obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// esc path-escapes an itemtype.
|
||||
func esc(s string) string { return url.PathEscape(s) }
|
||||
|
||||
// eqfold is strings.EqualFold kept local for the status table.
|
||||
func eqfold(a, b string) bool { return strings.EqualFold(a, b) }
|
||||
|
||||
// trimFloat renders a JSON number without a trailing ".0".
|
||||
func trimFloat(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// parseLeadingInt parses an int from a possibly messy search cell.
|
||||
func parseLeadingInt(s string) (int, error) {
|
||||
return strconv.Atoi(strings.TrimSpace(s))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package glpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Profile is one GLPI profile of the logged-in user (GET /getMyProfiles,
|
||||
// GET /Profile/<id>). IsActive reflects the session's active profile.
|
||||
type Profile struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// GetMyProfiles lists the profiles of the logged-in user. GLPI answers
|
||||
// with a bare JSON array (not a wrapped object).
|
||||
func (c *Client) GetMyProfiles(ctx context.Context) ([]Profile, error) {
|
||||
var profiles []Profile
|
||||
if err := c.call(ctx, http.MethodGet, "/getMyProfiles", nil, nil, &profiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// ChangeActiveProfile switches the session's active profile
|
||||
// (POST /changeActiveProfile with {"profiles_id":N}). Agent mode uses
|
||||
// this to move into the right role (e.g. 5 = Hotliner) after
|
||||
// InitSession; servers that gate operations per profile then accept the
|
||||
// calls that were previously rejected.
|
||||
func (c *Client) ChangeActiveProfile(ctx context.Context, profileID int) error {
|
||||
if profileID <= 0 {
|
||||
return fmt.Errorf("%w: profile id must be positive", ErrValidation)
|
||||
}
|
||||
payload := map[string]any{"profiles_id": profileID}
|
||||
// The endpoint answers `true` — nothing to parse.
|
||||
return c.call(ctx, http.MethodPost, "/changeActiveProfile", nil, payload, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user