Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions abm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
126 changes: 94 additions & 32 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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
}
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
31 changes: 29 additions & 2 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down