@@ -0,0 +1,591 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package glpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOAuthAndGetTicket(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/v2.3/Assistance/Ticket/5":
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": 5, "name": "Hello", "category": map[string]any{"id": 2}, "status": map[string]any{"id": 1}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "v2.3", "cid", "sec", "u", "p", time.Second)
|
||||
got, err := c.GetTicket(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != 5 || got.CategoryID != 2 {
|
||||
t.Fatalf("unexpected %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContractAcceptsVersionPrefixedOpenAPIPaths(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/Assistance/Ticket": map[string]any{"get": map[string]any{}},
|
||||
"/v2.3/Assistance/Ticket/{id}": map[string]any{"get": map[string]any{}, "patch": map[string]any{}},
|
||||
"/v2.3/Assistance/Ticket/{id}/Timeline/Followup": map[string]any{"get": map[string]any{}, "post": map[string]any{}},
|
||||
"/v2.3/Dropdowns/ITILCategory": map[string]any{"get": map[string]any{}},
|
||||
}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(srv.URL, "v2.3", "cid", "sec", "u", "p", time.Second)
|
||||
if err := c.ValidateContract(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user