Files
glpi-ai-agent/internal/glpi/client.go
jbergner 9b3227348d
All checks were successful
release-tag / release-image (push) Successful in 1m33s
init
2026-07-27 17:32:35 +02:00

592 lines
18 KiB
Go

package glpi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/example/glpi-ai-agent/internal/model"
)
type Client struct {
baseURL, version, clientID, clientSecret, username, password string
http *http.Client
mu sync.Mutex
token string
tokenExpiry time.Time
}
func New(baseURL, version, clientID, clientSecret, username, password string, timeout time.Duration) *Client {
return &Client{baseURL: strings.TrimRight(baseURL, "/"), version: version, clientID: clientID, clientSecret: clientSecret, username: username, password: password, http: &http.Client{Timeout: timeout}}
}
func (c *Client) APIBase() string { return c.baseURL + "/api.php/" + c.version }
func (c *Client) authenticate(ctx context.Context, force bool) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force && c.token != "" && time.Until(c.tokenExpiry) > 60*time.Second {
return c.token, nil
}
form := url.Values{"grant_type": {"password"}, "client_id": {c.clientID}, "client_secret": {c.clientSecret}, "username": {c.username}, "password": {c.password}, "scope": {"api"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api.php/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("GLPI OAuth failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var tr struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tr); err != nil {
return "", err
}
if tr.AccessToken == "" {
return "", errors.New("GLPI OAuth response contains no access_token")
}
if tr.ExpiresIn <= 0 {
tr.ExpiresIn = 3600
}
c.token = tr.AccessToken
c.tokenExpiry = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
return c.token, nil
}
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) ([]byte, http.Header, error) {
var payload []byte
var err error
if body != nil {
payload, err = json.Marshal(body)
if err != nil {
return nil, nil, err
}
}
for attempt := 0; attempt < 2; attempt++ {
tok, err := c.authenticate(ctx, attempt > 0)
if err != nil {
return nil, nil, err
}
u := c.APIBase() + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(payload))
if err != nil {
return nil, nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, nil, err
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
continue
}
if resp.StatusCode/100 != 2 {
return nil, resp.Header, fmt.Errorf("GLPI %s %s failed: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(b)))
}
return b, resp.Header, nil
}
return nil, nil, errors.New("GLPI request failed after token refresh")
}
func (c *Client) Ping(ctx context.Context) error {
_, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", url.Values{"limit": {"1"}}, nil)
return err
}
func (c *Client) FetchOpenAPI(ctx context.Context) (map[string]any, error) {
tok, err := c.authenticate(ctx, false)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api.php/doc.json", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("OpenAPI HTTP %d", resp.StatusCode)
}
var v map[string]any
return v, json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&v)
}
func (c *Client) ValidateContract(ctx context.Context) error {
doc, err := c.FetchOpenAPI(ctx)
if err != nil {
return fmt.Errorf("fetch GLPI OpenAPI: %w", err)
}
paths, ok := doc["paths"].(map[string]any)
if !ok {
return errors.New("GLPI OpenAPI document has no paths map")
}
required := map[string][]string{
"/Assistance/Ticket": {http.MethodGet},
"/Assistance/Ticket/{id}": {http.MethodGet, http.MethodPatch},
"/Assistance/Ticket/{id}/Timeline/Followup": {http.MethodGet, http.MethodPost},
"/Dropdowns/ITILCategory": {http.MethodGet},
}
for route, methods := range required {
op, found := openAPIOperations(paths, route)
if !found {
return fmt.Errorf("GLPI OpenAPI is missing required route %s; verify GLPI version/permissions", route)
}
for _, method := range methods {
if _, ok := op[strings.ToLower(method)]; !ok {
return fmt.Errorf("GLPI OpenAPI route %s does not expose %s; verify service-account permissions and API version", route, method)
}
}
}
return nil
}
func openAPIOperations(paths map[string]any, route string) (map[string]any, bool) {
for documented, raw := range paths {
// Depending on how the installed GLPI renders the schema, documented
// paths may include the version prefix. Match the canonical route suffix.
if documented != route && !strings.HasSuffix(documented, route) {
continue
}
ops, ok := raw.(map[string]any)
return ops, ok
}
return nil, false
}
func (c *Client) ListRecentTickets(ctx context.Context, limit int, filter string) ([]model.Ticket, error) {
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
if strings.TrimSpace(filter) != "" {
q.Set("filter", filter)
}
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", q, nil)
if err != nil {
return nil, err
}
arr, err := extractArray(b)
if err != nil {
return nil, err
}
out := make([]model.Ticket, 0, len(arr))
for _, raw := range arr {
out = append(out, decodeTicket(raw))
}
return out, nil
}
func (c *Client) GetTicket(ctx context.Context, id int64) (model.Ticket, error) {
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket/"+strconv.FormatInt(id, 10), nil, nil)
if err != nil {
return model.Ticket{}, err
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
return model.Ticket{}, err
}
return decodeTicket(raw), nil
}
func (c *Client) GetFollowups(ctx context.Context, id int64) ([]model.Followup, error) {
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket/"+strconv.FormatInt(id, 10)+"/Timeline/Followup", url.Values{"limit": {"100"}}, nil)
if err != nil {
return nil, err
}
arr, err := extractArray(b)
if err != nil {
return nil, err
}
out := make([]model.Followup, 0, len(arr))
for _, raw := range arr {
out = append(out, decodeFollowup(raw))
}
return out, nil
}
func (c *Client) SetCategory(ctx context.Context, id, categoryID int64) error {
_, _, err := c.do(ctx, http.MethodPatch, "/Assistance/Ticket/"+strconv.FormatInt(id, 10), nil, map[string]any{"category": map[string]any{"id": categoryID}})
return err
}
func (c *Client) AddFollowup(ctx context.Context, id int64, text string) error {
content := "<p>" + strings.ReplaceAll(html.EscapeString(strings.TrimSpace(text)), "\n", "<br>") + "</p>"
_, _, err := c.do(ctx, http.MethodPost, "/Assistance/Ticket/"+strconv.FormatInt(id, 10)+"/Timeline/Followup", nil, map[string]any{"content": content, "is_private": false})
return err
}
func (c *Client) GetCategories(ctx context.Context) ([]model.Category, error) {
b, _, err := c.do(ctx, http.MethodGet, "/Dropdowns/ITILCategory", url.Values{"limit": {"1000"}}, nil)
if err != nil {
return nil, err
}
arr, err := extractArray(b)
if err != nil {
return nil, err
}
out := make([]model.Category, 0, len(arr))
for _, r := range arr {
out = append(out, model.Category{ID: int64Val(r["id"]), Name: strVal(r["name"]), CompleteName: strVal(r["completename"])})
}
return out, nil
}
func extractArray(b []byte) ([]map[string]any, error) {
var arr []map[string]any
if json.Unmarshal(b, &arr) == nil {
return arr, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(b, &obj); err != nil {
return nil, err
}
for _, k := range []string{"data", "items", "results"} {
if raw, ok := obj[k]; ok && json.Unmarshal(raw, &arr) == nil {
return arr, nil
}
}
return nil, fmt.Errorf("unexpected GLPI collection response: %.200s", string(b))
}
func decodeTicket(r map[string]any) model.Ticket {
t := model.Ticket{ID: int64Val(r["id"]), Name: strVal(r["name"]), Content: strVal(r["content"]), DateMod: strVal(r["date_mod"]), StatusID: refID(r["status"]), CategoryID: firstRefID(r, "category", "itil_category", "itilcategory")}
t.RequesterIDs = extractRequesterIDs(r)
t.Items = extractLinkedItems(r)
return t
}
func extractRequesterIDs(r map[string]any) []int64 {
seen := map[int64]struct{}{}
var out []int64
add := func(v any) {
switch x := v.(type) {
case []any:
for _, e := range x {
addRequesterID(e, seen, &out)
}
default:
addRequesterID(x, seen, &out)
}
}
for _, k := range []string{"requester", "requesters", "users_requester", "users_requesters", "requester_users"} {
if v, ok := r[k]; ok {
add(v)
}
}
if actors, ok := r["actors"].([]any); ok {
for _, raw := range actors {
m, _ := raw.(map[string]any)
role := strings.ToLower(firstString(m, "role", "type", "actor_type"))
if strings.Contains(role, "request") || strings.Contains(role, "demande") {
addRequesterID(m, seen, &out)
}
}
}
return out
}
func addRequesterID(v any, seen map[int64]struct{}, out *[]int64) {
id := int64(0)
if m, ok := v.(map[string]any); ok {
id = firstRefID(m, "user", "requester")
if id == 0 {
id = int64Val(m["id"])
}
} else {
id = refID(v)
}
if id <= 0 {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
*out = append(*out, id)
}
func extractLinkedItems(r map[string]any) []model.LinkedItem {
seen := map[string]struct{}{}
var out []model.LinkedItem
visit := func(v any) {
arr, ok := v.([]any)
if !ok {
arr = []any{v}
}
for _, raw := range arr {
m, ok := raw.(map[string]any)
if !ok {
continue
}
typ := firstString(m, "itemtype", "type", "item_type")
id := int64Val(m["id"])
if id == 0 {
id = int64Val(m["items_id"])
}
if id == 0 {
id = firstRefID(m, "item")
}
if typ == "" {
if item, ok := m["item"].(map[string]any); ok {
typ = firstString(item, "itemtype", "type")
if id == 0 {
id = int64Val(item["id"])
}
}
}
if id <= 0 || typ == "" {
continue
}
key := strings.ToLower(typ) + ":" + strconv.FormatInt(id, 10)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, model.LinkedItem{ItemType: typ, ID: id, Name: firstString(m, "name", "completename")})
}
}
for _, k := range []string{"items", "assets", "associated_items", "linked_items", "item"} {
if v, ok := r[k]; ok {
visit(v)
}
}
if typ := firstString(r, "itemtype"); typ != "" {
if id := int64Val(r["items_id"]); id > 0 {
visit(map[string]any{"itemtype": typ, "id": id})
}
}
return out
}
func decodeFollowup(r map[string]any) model.Followup {
return model.Followup{ID: int64Val(r["id"]), Content: strVal(r["content"]), IsPrivate: boolVal(r["is_private"]), UserID: firstRefID(r, "user", "author", "user_editor"), Date: strVal(r["date"])}
}
func firstRefID(r map[string]any, keys ...string) int64 {
for _, k := range keys {
if v, ok := r[k]; ok {
if id := refID(v); id != 0 {
return id
}
}
}
return 0
}
func refID(v any) int64 {
if m, ok := v.(map[string]any); ok {
return int64Val(m["id"])
}
return int64Val(v)
}
func int64Val(v any) int64 {
switch x := v.(type) {
case float64:
return int64(x)
case int:
return int64(x)
case int64:
return x
case json.Number:
n, _ := x.Int64()
return n
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
}
return 0
}
func strVal(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
func boolVal(v any) bool {
switch x := v.(type) {
case bool:
return x
case float64:
return x != 0
case string:
return x == "1" || strings.EqualFold(x, "true")
}
return false
}
// ValidateReadRoutes checks optional, read-only context routes against the
// OpenAPI document exposed by the installed GLPI instance. This keeps optional
// integrations explicit and catches renamed/unavailable routes at startup.
func (c *Client) ValidateReadRoutes(ctx context.Context, routes []string) error {
if len(routes) == 0 {
return nil
}
doc, err := c.FetchOpenAPI(ctx)
if err != nil {
return fmt.Errorf("fetch GLPI OpenAPI: %w", err)
}
paths, ok := doc["paths"].(map[string]any)
if !ok {
return errors.New("GLPI OpenAPI document has no paths map")
}
for _, route := range routes {
op, found := openAPIOperations(paths, route)
if !found {
return fmt.Errorf("GLPI OpenAPI is missing optional context route %s", route)
}
if _, ok := op[strings.ToLower(http.MethodGet)]; !ok {
return fmt.Errorf("GLPI context route %s does not expose GET", route)
}
}
return nil
}
func (c *Client) ListChanges(ctx context.Context, path string, limit int, filter string) ([]model.ChangeContext, error) {
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
if strings.TrimSpace(filter) != "" {
q.Set("filter", filter)
}
b, _, err := c.do(ctx, http.MethodGet, path, q, nil)
if err != nil {
return nil, err
}
arr, err := extractArray(b)
if err != nil {
return nil, err
}
out := make([]model.ChangeContext, 0, len(arr))
for _, r := range arr {
out = append(out, model.ChangeContext{
ID: int64Val(r["id"]),
Name: strVal(r["name"]),
Content: firstString(r, "content", "description"),
StatusID: firstRefID(r, "status"),
CategoryID: firstRefID(r, "category", "itil_category", "itilcategory"),
PlannedBegin: firstString(r, "planned_begin", "planned_start", "date_begin"),
PlannedEnd: firstString(r, "planned_end", "planned_finish", "date_end"),
DateMod: firstString(r, "date_mod", "modified_at"),
Source: "glpi-change",
})
}
return out, nil
}
func (c *Client) ListMajorIncidents(ctx context.Context, limit int, filter string) ([]model.MajorIncidentContext, error) {
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
if strings.TrimSpace(filter) != "" {
q.Set("filter", filter)
}
b, _, err := c.do(ctx, http.MethodGet, "/Assistance/Ticket", q, nil)
if err != nil {
return nil, err
}
arr, err := extractArray(b)
if err != nil {
return nil, err
}
out := make([]model.MajorIncidentContext, 0, len(arr))
for _, r := range arr {
out = append(out, model.MajorIncidentContext{
ID: int64Val(r["id"]),
Name: strVal(r["name"]),
Content: firstString(r, "content", "description"),
StatusID: firstRefID(r, "status"),
CategoryID: firstRefID(r, "category", "itil_category", "itilcategory"),
Priority: int64Val(r["priority"]),
Impact: int64Val(r["impact"]),
Urgency: int64Val(r["urgency"]),
DateMod: firstString(r, "date_mod", "modified_at"),
Source: "glpi-major-incident",
})
}
return out, nil
}
// ListUserDevices searches one or more read-only asset collection routes for
// assets assigned to a requester. The filter template is configuration-driven
// because field aliases can differ with GLPI API versions/plugins.
func (c *Client) ListUserDevices(ctx context.Context, userID int64, paths []string, filterTemplate string, limit int) ([]model.UserDeviceContext, error) {
if userID <= 0 {
return nil, nil
}
filter := strings.ReplaceAll(filterTemplate, "{{user_id}}", strconv.FormatInt(userID, 10))
var out []model.UserDeviceContext
for _, path := range paths {
q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
if strings.TrimSpace(filter) != "" {
q.Set("filter", filter)
}
b, _, err := c.do(ctx, http.MethodGet, path, q, nil)
if err != nil {
return nil, fmt.Errorf("query %s for user %d: %w", path, userID, err)
}
arr, err := extractArray(b)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
itemType := strings.TrimPrefix(path[strings.LastIndex(path, "/"):], "/")
for _, r := range arr {
out = append(out, model.UserDeviceContext{
UserID: userID,
ItemType: itemType,
ID: int64Val(r["id"]),
Name: strVal(r["name"]),
Serial: firstString(r, "serial", "serial_number"),
InventoryNumber: firstString(r, "otherserial", "inventory_number", "inventory_no"),
Status: refName(r["status"]),
Location: refName(r["location"]),
LastInventoryDate: firstString(r, "last_inventory_update", "last_inventory_date", "date_mod"),
Source: "glpi-user-device",
})
if len(out) >= limit {
return out[:limit], nil
}
}
}
return out, nil
}
func firstString(r map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := r[k]; ok {
s := strings.TrimSpace(strVal(v))
if s != "" && s != "<nil>" {
return s
}
}
}
return ""
}
func refName(v any) string {
if m, ok := v.(map[string]any); ok {
for _, k := range []string{"completename", "name", "label"} {
if s := strings.TrimSpace(strVal(m[k])); s != "" && s != "<nil>" {
return s
}
}
}
return strings.TrimSpace(strVal(v))
}