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 != "" { 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 } }