This commit is contained in:
+168
-1
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -247,11 +248,177 @@ func (c *Client) GetCategories(ctx context.Context) ([]model.Category, error) {
|
||||
}
|
||||
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"])})
|
||||
out = append(out, model.Category{ID: int64Val(r["id"]), Name: strVal(r["name"]), CompleteName: strVal(r["completename"]), KnowbaseCategoryID: firstRefID(r, "knowbase_category", "knowbasecategory")})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiscoverKnowledgeBasePath finds the read-only KnowbaseItem collection route
|
||||
// from the OpenAPI document of the installed GLPI instance. This avoids
|
||||
// hard-coding a path that may move between high-level API versions.
|
||||
func (c *Client) DiscoverKnowledgeBasePath(ctx context.Context, configured string) (string, error) {
|
||||
configured = strings.TrimSpace(configured)
|
||||
if configured != "" && !strings.EqualFold(configured, "auto") {
|
||||
if err := c.ValidateReadRoutes(ctx, []string{configured}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return configured, 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
l := strings.ToLower(path)
|
||||
score := 0
|
||||
if strings.Contains(l, "knowbaseitem") {
|
||||
score += 100
|
||||
}
|
||||
if strings.Contains(l, "knowledge") {
|
||||
score += 40
|
||||
}
|
||||
if strings.Contains(l, "knowbase") {
|
||||
score += 40
|
||||
}
|
||||
if strings.HasSuffix(l, "/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; verify GLPI version and service-account knowledge-base rights")
|
||||
}
|
||||
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
|
||||
// The OpenAPI document may include /api.php/vX.Y in documented paths while
|
||||
// c.do already prepends the configured API base. Keep only the route suffix.
|
||||
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 := "/" + strings.Trim(c.version, "/")
|
||||
if strings.HasPrefix(path, versionPrefix+"/") {
|
||||
path = strings.TrimPrefix(path, versionPrefix)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ListKnowledgeBaseItems reads only items visible to the authenticated GLPI
|
||||
// service account. Visibility is therefore enforced by GLPI itself; an
|
||||
// optional filter can further restrict the collection on a per-instance basis.
|
||||
func (c *Client) ListKnowledgeBaseItems(ctx context.Context, path string, limit int, filter string) ([]model.GLPIKnowledgeItem, 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.GLPIKnowledgeItem, 0, len(arr))
|
||||
for _, r := range arr {
|
||||
id := int64Val(r["id"])
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if firstString(r, "answer", "content", "text", "description") == "" {
|
||||
if detail, _, e := c.do(ctx, http.MethodGet, strings.TrimRight(path, "/")+"/"+strconv.FormatInt(id, 10), nil, nil); e == nil {
|
||||
var full map[string]any
|
||||
if json.Unmarshal(detail, &full) == nil {
|
||||
for k, v := range full {
|
||||
r[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
title := firstString(r, "name", "title", "subject")
|
||||
content := firstString(r, "answer", "content", "text", "description")
|
||||
if title == "" || content == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, model.GLPIKnowledgeItem{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Content: content,
|
||||
CategoryIDs: knowledgeCategoryIDs(r),
|
||||
Language: firstString(r, "language", "locale"),
|
||||
ModifiedAt: firstString(r, "date_mod", "modified_at", "date_creation"),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func knowledgeCategoryIDs(r map[string]any) []int64 {
|
||||
seen := map[int64]struct{}{}
|
||||
var out []int64
|
||||
var add func(any)
|
||||
add = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
for _, e := range x {
|
||||
add(e)
|
||||
}
|
||||
case map[string]any:
|
||||
if id := int64Val(x["id"]); id > 0 {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, v2 := range x {
|
||||
add(v2)
|
||||
}
|
||||
default:
|
||||
if id := int64Val(x); id > 0 {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"knowbase_category", "knowbase_categories", "knowbaseitemcategory", "knowbaseitemcategories", "categories", "category"} {
|
||||
if v, ok := r[k]; ok {
|
||||
add(v)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func extractArray(b []byte) ([]map[string]any, error) {
|
||||
var arr []map[string]any
|
||||
if json.Unmarshal(b, &arr) == nil {
|
||||
|
||||
@@ -53,3 +53,39 @@ func TestValidateContractAcceptsVersionPrefixedOpenAPIPaths(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAndListKnowledgeBase(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api.php/token":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "x", "expires_in": 3600})
|
||||
case "/api.php/doc.json":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"paths": map[string]any{
|
||||
"/v2.3/Knowledge/KnowbaseItem": map[string]any{"get": map[string]any{}},
|
||||
}})
|
||||
case "/api.php/v2.3/Knowledge/KnowbaseItem":
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{{
|
||||
"id": 7, "name": "Konto gesperrt", "answer": "<p>Konto entsperren</p>", "date_mod": "2026-07-27 10:00:00",
|
||||
"categories": []any{map[string]any{"id": 4}},
|
||||
}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "v2.3", "cid", "sec", "u", "p", time.Second)
|
||||
path, err := c.DiscoverKnowledgeBasePath(context.Background(), "auto")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != "/Knowledge/KnowbaseItem" {
|
||||
t.Fatalf("path=%q", path)
|
||||
}
|
||||
items, err := c.ListKnowledgeBaseItems(context.Background(), path, 50, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].ID != 7 || len(items[0].CategoryIDs) != 1 || items[0].CategoryIDs[0] != 4 {
|
||||
t.Fatalf("unexpected items: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user