387 lines
11 KiB
Go
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
|
|
}
|
|
}
|