diff --git a/abm.go b/abm.go index 161ce6d..8b9662c 100644 --- a/abm.go +++ b/abm.go @@ -47,6 +47,58 @@ func (c *Client) FetchOrgDevicePartNumbers(ctx context.Context) ([]string, error return partNumbers, nil } +// FetchAllOrgDevices returns all org devices for the organization, +// automatically following pagination until all pages are consumed. +// It also returns the total device count from the first page's metadata. +func (c *Client) FetchAllOrgDevices(ctx context.Context) ([]OrgDevice, int, error) { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + + baseURL, err := c.buildURL(orgDevicesPath, nil) + if err != nil { + return nil, 0, err + } + + var all []OrgDevice + var total int + firstPage := true + + for pageResult, err := range PageIterator(ctx, c.httpClient, decodeOrgDevicesPage, baseURL) { + if err != nil { + return all, total, err + } + if firstPage { + total = pageResult.total + firstPage = false + } + all = append(all, pageResult.devices...) + } + + return all, total, nil +} + +type orgDevicesPageResult struct { + devices []OrgDevice + total int +} + +func decodeOrgDevicesPage(payload []byte) (orgDevicesPageResult, string, error) { + var response OrgDevicesResponse + if err := json.Unmarshal(payload, &response); err != nil { + return orgDevicesPageResult{}, "", fmt.Errorf("decode org devices response: %w", err) + } + + result := orgDevicesPageResult{ + devices: response.Data, + } + if response.Meta != nil { + result.total = response.Meta.Paging.Total + } + + return result, response.Links.Next, nil +} + func decodeOrgDevices(payload []byte) ([]string, string, error) { var response OrgDevicesResponse if err := json.Unmarshal(payload, &response); err != nil { diff --git a/client.go b/client.go index 74d5f66..c7f9479 100644 --- a/client.go +++ b/client.go @@ -26,11 +26,43 @@ import ( "slices" "strconv" "strings" + "time" "github.com/go-json-experiment/json" "golang.org/x/oauth2" + "golang.org/x/time/rate" ) +const ( + // defaultMaxRetries is the default maximum number of retries for rate-limited requests. + defaultMaxRetries = 5 + + // defaultInitialBackoff is the initial backoff duration for retries. + defaultInitialBackoff = time.Second + + // defaultRateLimit is the proactive rate limit in requests per second. + // Apple's ABM API has an observed limit of ~20 requests/minute. + // 1 request every 3 seconds = 20/min, staying just at the limit. + defaultRateLimit = rate.Limit(1.0 / 3.0) + + // defaultRateBurst is the maximum burst size for the rate limiter. + defaultRateBurst = 1 +) + +// rateLimitTransport wraps an http.RoundTripper with proactive rate limiting +// to avoid hitting Apple's 429 rate limits. +type rateLimitTransport struct { + base http.RoundTripper + limiter *rate.Limiter +} + +func (t *rateLimitTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.limiter.Wait(req.Context()); err != nil { + return nil, err + } + return t.base.RoundTrip(req) +} + const ( // DefaultAPIBaseURL is the default Apple Business Manager API base URL. DefaultAPIBaseURL = "https://api-business.apple.com/" @@ -139,7 +171,10 @@ func NewClientWithBaseURL(httpClient *http.Client, tokenSource oauth2.TokenSourc authorizedClient := *httpClient authorizedClient.Transport = &oauth2.Transport{ - Base: baseTransport, + Base: &rateLimitTransport{ + base: baseTransport, + limiter: rate.NewLimiter(defaultRateLimit, defaultRateBurst), + }, Source: tokenSource, } @@ -480,42 +515,69 @@ func (c *Client) doJSONRequest(ctx context.Context, method, path string, query u } } - requestReader := io.Reader(http.NoBody) - if len(body) > 0 { - requestReader = bytes.NewReader(body) - } + backoff := defaultInitialBackoff - req, err := http.NewRequestWithContext(ctx, method, requestURL, requestReader) - if err != nil { - return fmt.Errorf("build request: %w", err) - } - req.Header.Set("Accept", "application/json") - if len(body) > 0 { - req.Header.Set("Content-Type", "application/json") - } + for attempt := 0; ; attempt++ { + if err := ctx.Err(); err != nil { + return err + } - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("send request: %w", err) - } - defer resp.Body.Close() + requestReader := io.Reader(http.NoBody) + if len(body) > 0 { + requestReader = bytes.NewReader(body) + } - payload, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read response body: %w", err) - } + req, err := http.NewRequestWithContext(ctx, method, requestURL, requestReader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } - if !statusAllowed(resp.StatusCode, expectedStatusCodes) { - return decodeAPIError(resp, payload) - } + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } - if responseBody == nil || len(payload) == 0 { - return nil - } + payload, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("read response body: %w", err) + } - if err := json.Unmarshal(payload, responseBody); err != nil { - return fmt.Errorf("decode response body: %w", err) - } + // Retry on 429 Too Many Requests. + // Apple's ABM API has unpublished rate limits and returns 429 + // when they are exceeded, with a Retry-After header in seconds. + if resp.StatusCode == http.StatusTooManyRequests && attempt < defaultMaxRetries { + wait := backoff + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil { + wait = time.Duration(secs) * time.Second + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + backoff *= 2 + continue + } - return nil + if !statusAllowed(resp.StatusCode, expectedStatusCodes) { + return decodeAPIError(resp, payload) + } + + if responseBody == nil || len(payload) == 0 { + return nil + } + + if err := json.Unmarshal(payload, responseBody); err != nil { + return fmt.Errorf("decode response body: %w", err) + } + + return nil + } } diff --git a/go.mod b/go.mod index 7ce8282..178561e 100644 --- a/go.mod +++ b/go.mod @@ -9,3 +9,5 @@ require ( github.com/google/uuid v1.6.0 golang.org/x/oauth2 v0.35.0 ) + +require golang.org/x/time v0.14.0 // indirect diff --git a/go.sum b/go.sum index 324ef44..0388650 100644 --- a/go.sum +++ b/go.sum @@ -8,3 +8,5 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/types.go b/types.go index 2cb2f74..49c38a5 100644 --- a/types.go +++ b/types.go @@ -17,9 +17,36 @@ package abm import ( + "encoding/json" "time" ) +// FlexStringSlice handles JSON fields that may be a single string or an array of strings. +// The ABM API is inconsistent about some fields (e.g. wifiMacAddress) — returning +// a string for single values and an array for multiple values. +type FlexStringSlice []string + +// UnmarshalJSON implements json.Unmarshaler, accepting both a string and an array of strings. +func (f *FlexStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Try array first + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *f = arr + return nil + } + // Try single string + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *f = []string{s} + return nil +} + // OrgDevicesResponse contains a list of organization device resources. type OrgDevicesResponse struct { Data []OrgDevice `json:"data"` @@ -81,8 +108,8 @@ type OrgDeviceAttributes struct { EID string `json:"eid,omitzero"` IMEI []string `json:"imei,omitempty"` MEID []string `json:"meid,omitempty"` - WifiMacAddress []string `json:"wifiMacAddress,omitempty"` - BluetoothMacAddress []string `json:"bluetoothMacAddress,omitempty"` + WifiMacAddress FlexStringSlice `json:"wifiMacAddress,omitempty"` + BluetoothMacAddress FlexStringSlice `json:"bluetoothMacAddress,omitempty"` EthernetMacAddress []string `json:"ethernetMacAddress,omitempty"` OrderDateTime time.Time `json:"orderDateTime,omitzero"` OrderNumber string `json:"orderNumber,omitzero"`