11package api
22
33import (
4- "bytes"
54 "context"
65 "encoding/json"
76 "fmt"
8- "io"
97 "net/http"
108 "net/url"
119 "strings"
12- "time"
10+
11+ "github.com/enthus-appdev/atl-cli/internal/auth"
1312)
1413
1514// AssetsClient talks to the Jira Service Management Assets (CMDB) REST API.
1615//
17- // Assets lives on a different base URL than the Jira site API
18- // (https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1) and the
19- // granular OAuth scopes the rest of atl uses do not cover CMDB objects, so this
20- // client authenticates with Basic auth (account email + API token) instead of
21- // the shared OAuth client. The token is read from the environment so it never
22- // lands in the on-disk config.
16+ // Assets lives below a different path than the Jira platform API, but supports
17+ // the same OAuth 2.0 access token when requests use the cloud gateway URL.
2318type AssetsClient struct {
24- httpClient * http.Client
25- email string
26- token string
19+ client * Client
2720 workspaceID string
28- siteBase string // https://<hostname>, used only for workspace discovery
21+ baseURL string
2922}
3023
31- const assetsAPIBase = "https://api.atlassian.com/jsm/assets/workspace"
32-
33- // NewAssetsClient builds an Assets client. email and token are required;
34- // workspaceID may be empty, in which case it is discovered from the site.
35- func NewAssetsClient (siteBase , email , token , workspaceID string ) * AssetsClient {
24+ // NewAssetsClient builds an OAuth-backed Assets client. workspaceID may be
25+ // empty, in which case it is discovered through Jira Service Management.
26+ func NewAssetsClient (client * Client , workspaceID string ) * AssetsClient {
3627 return & AssetsClient {
37- httpClient : & http.Client {Timeout : 60 * time .Second },
38- email : email ,
39- token : token ,
28+ client : client ,
4029 workspaceID : workspaceID ,
41- siteBase : strings . TrimRight ( siteBase , "/" ),
30+ baseURL : client . JiraGatewayBaseURL ( ),
4231 }
4332}
4433
45- func (c * AssetsClient ) do (ctx context.Context , method , fullURL string , body []byte , out interface {}) error {
46- var rdr io.Reader
47- if body != nil {
48- rdr = bytes .NewReader (body )
49- }
50- req , err := http .NewRequestWithContext (ctx , method , fullURL , rdr )
51- if err != nil {
52- return err
53- }
54- req .SetBasicAuth (c .email , c .token )
55- req .Header .Set ("Accept" , "application/json" )
56- if body != nil {
57- req .Header .Set ("Content-Type" , "application/json" )
58- }
59- resp , err := c .httpClient .Do (req )
60- if err != nil {
61- return err
62- }
63- defer resp .Body .Close ()
64- if resp .StatusCode < 200 || resp .StatusCode >= 300 {
65- dec := json .NewDecoder (resp .Body )
66- var apiErr struct {
67- ErrorMessages []string `json:"errorMessages"`
68- }
69- _ = dec .Decode (& apiErr )
70- if len (apiErr .ErrorMessages ) > 0 {
71- return fmt .Errorf ("assets API %s: %d: %s" , method , resp .StatusCode , strings .Join (apiErr .ErrorMessages , "; " ))
72- }
73- return fmt .Errorf ("assets API %s: unexpected status %d" , method , resp .StatusCode )
74- }
75- if out == nil {
34+ func (c * AssetsClient ) do (ctx context.Context , method , fullURL string , body , out interface {}) error {
35+ return c .client .Request (ctx , method , fullURL , body , out )
36+ }
37+
38+ func (c * AssetsClient ) requireScopes (required ... string ) error {
39+ missing := c .client .MissingScopes (required ... )
40+ if len (missing ) == 0 {
7641 return nil
7742 }
78- return json .NewDecoder (resp .Body ).Decode (out )
43+ return fmt .Errorf ("OAuth token is missing Assets scopes %s; re-authenticate with 'atl auth login --hostname %s'" ,
44+ strings .Join (missing , ", " ), c .client .Hostname ())
7945}
8046
8147// WorkspaceID returns the resolved workspace id, discovering it from the site if
@@ -84,19 +50,16 @@ func (c *AssetsClient) WorkspaceID(ctx context.Context) (string, error) {
8450 if c .workspaceID != "" {
8551 return c .workspaceID , nil
8652 }
87- if c .siteBase == "" {
88- return "" , fmt .Errorf ("workspace id not set and no site to discover it from (pass --workspace or set ATLASSIAN_ASSETS_WORKSPACE)" )
89- }
9053 var out struct {
9154 Values []struct {
9255 WorkspaceID string `json:"workspaceId"`
9356 } `json:"values"`
9457 }
95- if err := c .do (ctx , http .MethodGet , c .siteBase + "/rest/servicedeskapi/assets/workspace" , nil , & out ); err != nil {
58+ if err := c .do (ctx , http .MethodGet , c .baseURL + "/rest/servicedeskapi/assets/workspace" , nil , & out ); err != nil {
9659 return "" , fmt .Errorf ("discovering assets workspace: %w" , err )
9760 }
9861 if len (out .Values ) == 0 {
99- return "" , fmt .Errorf ("no assets workspace found for site %s" , c .siteBase )
62+ return "" , fmt .Errorf ("no assets workspace found for %s" , c .client . Hostname () )
10063 }
10164 c .workspaceID = out .Values [0 ].WorkspaceID
10265 return c .workspaceID , nil
@@ -107,7 +70,7 @@ func (c *AssetsClient) v1(ctx context.Context) (string, error) {
10770 if err != nil {
10871 return "" , err
10972 }
110- return fmt .Sprintf ("%s/%s/v1" , assetsAPIBase , ws ), nil
73+ return fmt .Sprintf ("%s/jsm/assets/workspace/ %s/v1" , c . baseURL , url . PathEscape ( ws ) ), nil
11174}
11275
11376// AssetSchema is one object schema with its current object count.
@@ -121,6 +84,9 @@ type AssetSchema struct {
12184
12285// Schemas returns all object schemas in the workspace.
12386func (c * AssetsClient ) Schemas (ctx context.Context ) ([]AssetSchema , error ) {
87+ if err := c .requireScopes (auth .AssetsSchemaReadScope ); err != nil {
88+ return nil , err
89+ }
12490 base , err := c .v1 (ctx )
12591 if err != nil {
12692 return nil , err
@@ -136,14 +102,35 @@ func (c *AssetsClient) Schemas(ctx context.Context) ([]AssetSchema, error) {
136102
137103// AssetObject is a single Assets object (trimmed to the useful fields).
138104type AssetObject struct {
139- ID string `json:"id"`
140- ObjectKey string `json:"objectKey"`
141- Label string `json:"label"`
142- Created string `json:"created"`
143- Updated string `json:"updated"`
144- ObjectType struct {
105+ WorkspaceID string `json:"workspaceId,omitempty"`
106+ GlobalID string `json:"globalId,omitempty"`
107+ ID string `json:"id"`
108+ ObjectKey string `json:"objectKey"`
109+ Label string `json:"label"`
110+ Created string `json:"created"`
111+ Updated string `json:"updated"`
112+ ObjectType struct {
113+ ID string `json:"id,omitempty"`
145114 Name string `json:"name"`
146115 } `json:"objectType"`
116+ Attributes []AssetAttribute `json:"attributes,omitempty"`
117+ }
118+
119+ // AssetAttribute is one named attribute and its values on an Assets object.
120+ type AssetAttribute struct {
121+ ID string `json:"id,omitempty"`
122+ ObjectTypeAttributeID string `json:"objectTypeAttributeId"`
123+ ObjectTypeAttribute struct {
124+ Name string `json:"name"`
125+ } `json:"objectTypeAttribute"`
126+ ObjectAttributeValues []AssetAttributeValue `json:"objectAttributeValues,omitempty"`
127+ }
128+
129+ // AssetAttributeValue preserves both the API value and its display form.
130+ type AssetAttributeValue struct {
131+ Value json.RawMessage `json:"value,omitempty"`
132+ DisplayValue string `json:"displayValue,omitempty"`
133+ SearchValue string `json:"searchValue,omitempty"`
147134}
148135
149136type aqlPage struct {
@@ -153,6 +140,9 @@ type aqlPage struct {
153140
154141// AQLPage runs an AQL query and returns one page of objects.
155142func (c * AssetsClient ) AQLPage (ctx context.Context , ql string , startAt , maxResults int ) ([]AssetObject , bool , error ) {
143+ if err := c .requireScopes (auth .AssetsObjectReadScope ); err != nil {
144+ return nil , false , err
145+ }
156146 base , err := c .v1 (ctx )
157147 if err != nil {
158148 return nil , false , err
@@ -161,14 +151,36 @@ func (c *AssetsClient) AQLPage(ctx context.Context, ql string, startAt, maxResul
161151 q .Set ("startAt" , fmt .Sprint (startAt ))
162152 q .Set ("maxResults" , fmt .Sprint (maxResults ))
163153 q .Set ("includeAttributes" , "false" )
164- body , _ := json .Marshal (map [string ]string {"qlQuery" : ql })
165154 var page aqlPage
166- if err := c .do (ctx , http .MethodPost , base + "/object/aql?" + q .Encode (), body , & page ); err != nil {
155+ if err := c .do (ctx , http .MethodPost , base + "/object/aql?" + q .Encode (), map [ string ] string { "qlQuery" : ql } , & page ); err != nil {
167156 return nil , false , err
168157 }
169158 return page .Values , page .IsLast , nil
170159}
171160
161+ // Object loads an Assets object and all attributes returned by the API.
162+ func (c * AssetsClient ) Object (ctx context.Context , objectID string ) (* AssetObject , error ) {
163+ if err := c .requireScopes (auth .AssetsObjectReadScope ); err != nil {
164+ return nil , err
165+ }
166+ workspaceID , err := c .WorkspaceID (ctx )
167+ if err != nil {
168+ return nil , err
169+ }
170+ base , err := c .v1 (ctx )
171+ if err != nil {
172+ return nil , err
173+ }
174+ var object AssetObject
175+ if err := c .do (ctx , http .MethodGet , base + "/object/" + url .PathEscape (objectID ), nil , & object ); err != nil {
176+ return nil , err
177+ }
178+ if object .WorkspaceID != "" && object .WorkspaceID != workspaceID {
179+ return nil , fmt .Errorf ("assets object %s belongs to workspace %s, expected %s" , objectID , object .WorkspaceID , workspaceID )
180+ }
181+ return & object , nil
182+ }
183+
172184// AQLCount returns the exact number of objects matching an AQL query by
173185// paginating through every page. The object/aql endpoint caps its reported
174186// `total` at 1000, so it cannot be trusted for counting — this walks instead.
0 commit comments