Skip to content

Commit 77abc00

Browse files
authored
Add explicit contexts and OAuth-backed Jira Assets (#74)
* feat: add explicit contexts and OAuth Assets * fix: skip null asset values * fix: align context diagnostics and docs * test: cover assets gateway construction * fix: scope invocation context lifecycle * fix: harden assets context boundaries * fix: finalize assets context safety * fix: parse context with cobra semantics
1 parent d9646ae commit 77abc00

22 files changed

Lines changed: 910 additions & 360 deletions

‎AGENTS.md‎

Lines changed: 137 additions & 108 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 130 additions & 115 deletions
Large diffs are not rendered by default.

‎internal/api/assets.go‎

Lines changed: 80 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,47 @@
11
package api
22

33
import (
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.
2318
type 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.
12386
func (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).
138104
type 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

149136
type aqlPage struct {
@@ -153,6 +140,9 @@ type aqlPage struct {
153140

154141
// AQLPage runs an AQL query and returns one page of objects.
155142
func (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

Comments
 (0)