Files
glpi-neural-brain/internal/glpi/client.go
2026-08-04 05:27:55 +02:00

387 lines
11 KiB
Go

package glpi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type KnowledgeItem struct {
ID int64
Title string
Content string
CategoryIDs []int64
Language string
ModifiedAt string
}
type ITILCategory struct {
ID int64
Name string
CompleteName string
KnowbaseCategoryID int64
}
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: strings.Trim(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 token struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &token); err != nil {
return "", err
}
if token.AccessToken == "" {
return "", errors.New("GLPI OAuth response contains no access_token")
}
if token.ExpiresIn <= 0 {
token.ExpiresIn = 3600
}
c.token = token.AccessToken
c.tokenExpiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
return c.token, nil
}
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) ([]byte, error) {
var payload []byte
var err error
if body != nil {
payload, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
for attempt := 0; attempt < 2; attempt++ {
token, err := c.authenticate(ctx, attempt > 0)
if err != nil {
return 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, err
}
req.Header.Set("Authorization", "Bearer "+token)
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, err
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
continue
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("GLPI %s %s failed: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, errors.New("GLPI request failed after token refresh")
}
func (c *Client) FetchOpenAPI(ctx context.Context) (map[string]any, error) {
token, 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 "+token)
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("GLPI OpenAPI HTTP %d", resp.StatusCode)
}
var doc map[string]any
if err := json.NewDecoder(io.LimitReader(resp.Body, 16<<20)).Decode(&doc); err != nil {
return nil, err
}
return doc, nil
}
func (c *Client) DiscoverKnowledgeBasePath(ctx context.Context, configured string) (string, error) {
configured = strings.TrimSpace(configured)
if configured != "" && !strings.EqualFold(configured, "auto") {
if !strings.HasPrefix(configured, "/") {
return "", errors.New("GLPI KB path must be absolute")
}
return configured, nil
}
doc, err := c.FetchOpenAPI(ctx)
if err != nil {
return "", err
}
paths, ok := doc["paths"].(map[string]any)
if !ok {
return "", errors.New("GLPI OpenAPI document has no paths map")
}
type candidate struct {
path string
score int
}
var candidates []candidate
for path, raw := range paths {
if strings.Contains(path, "{") {
continue
}
ops, ok := raw.(map[string]any)
if !ok || ops["get"] == nil {
continue
}
lower := strings.ToLower(path)
score := 0
if strings.Contains(lower, "knowbaseitem") {
score += 100
}
if strings.Contains(lower, "knowledge") {
score += 40
}
if strings.Contains(lower, "knowbase") {
score += 40
}
if strings.HasSuffix(lower, "/knowbaseitem") {
score += 30
}
if score > 0 {
candidates = append(candidates, candidate{path: path, score: score})
}
}
if len(candidates) == 0 {
return "", errors.New("GLPI OpenAPI exposes no readable KnowbaseItem collection route")
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].score == candidates[j].score {
return candidates[i].path < candidates[j].path
}
return candidates[i].score > candidates[j].score
})
path := candidates[0].path
if i := strings.Index(path, "/api.php/"); i >= 0 {
rest := path[i+len("/api.php/"):]
if slash := strings.Index(rest, "/"); slash >= 0 {
path = rest[slash:]
}
}
versionPrefix := "/" + c.version
if strings.HasPrefix(path, versionPrefix+"/") {
path = strings.TrimPrefix(path, versionPrefix)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path, nil
}
func (c *Client) ListKnowledgeBaseItems(ctx context.Context, path string, limit int, filter string) ([]KnowledgeItem, error) {
query := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}}
if strings.TrimSpace(filter) != "" {
query.Set("filter", filter)
}
data, err := c.do(ctx, http.MethodGet, path, query, nil)
if err != nil {
return nil, err
}
items, err := extractArray(data)
if err != nil {
return nil, err
}
out := make([]KnowledgeItem, 0, len(items))
for _, raw := range items {
id := int64Val(raw["id"])
if id <= 0 {
continue
}
if firstString(raw, "answer", "content", "text", "description") == "" {
if detail, detailErr := c.do(ctx, http.MethodGet, strings.TrimRight(path, "/")+"/"+strconv.FormatInt(id, 10), nil, nil); detailErr == nil {
var full map[string]any
if json.Unmarshal(detail, &full) == nil {
for k, v := range full {
raw[k] = v
}
}
}
}
title := firstString(raw, "name", "title", "subject")
content := firstString(raw, "answer", "content", "text", "description")
if title == "" || content == "" {
continue
}
out = append(out, KnowledgeItem{ID: id, Title: title, Content: content, CategoryIDs: knowledgeCategoryIDs(raw), Language: firstString(raw, "language", "locale"), ModifiedAt: firstString(raw, "date_mod", "modified_at", "date_creation")})
}
return out, nil
}
func (c *Client) GetCategories(ctx context.Context) ([]ITILCategory, error) {
data, err := c.do(ctx, http.MethodGet, "/Dropdowns/ITILCategory", url.Values{"limit": {"1000"}}, nil)
if err != nil {
return nil, err
}
items, err := extractArray(data)
if err != nil {
return nil, err
}
out := make([]ITILCategory, 0, len(items))
for _, raw := range items {
out = append(out, ITILCategory{ID: int64Val(raw["id"]), Name: firstString(raw, "name"), CompleteName: firstString(raw, "completename"), KnowbaseCategoryID: firstRefID(raw, "knowbase_category", "knowbasecategory")})
}
return out, nil
}
func extractArray(data []byte) ([]map[string]any, error) {
var arr []map[string]any
if json.Unmarshal(data, &arr) == nil {
return arr, nil
}
var object map[string]json.RawMessage
if err := json.Unmarshal(data, &object); err != nil {
return nil, err
}
for _, key := range []string{"data", "items", "results"} {
if raw, ok := object[key]; ok && json.Unmarshal(raw, &arr) == nil {
return arr, nil
}
}
return nil, fmt.Errorf("unexpected GLPI collection response: %.200s", string(data))
}
func knowledgeCategoryIDs(raw map[string]any) []int64 {
seen := map[int64]struct{}{}
var out []int64
var add func(any)
add = func(v any) {
switch value := v.(type) {
case []any:
for _, item := range value {
add(item)
}
case map[string]any:
if id := int64Val(value["id"]); id > 0 {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
return
}
for _, nested := range value {
add(nested)
}
default:
if id := int64Val(value); id > 0 {
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
}
}
}
for _, key := range []string{"knowbase_category", "knowbase_categories", "knowbaseitemcategory", "knowbaseitemcategories", "categories", "category"} {
if value, ok := raw[key]; ok {
add(value)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func firstRefID(raw map[string]any, keys ...string) int64 {
for _, key := range keys {
if value, ok := raw[key]; ok {
if id := refID(value); 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 firstString(raw map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := raw[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
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
default:
return 0
}
}