Skip to content

Commit 616b812

Browse files
authored
Move LRO and payload helpers to the internal module (Azure#20539)
* Move LRO and payload helpers to the internal module This simplifies the creation of custom PollerHandler[T] by SDK authors. The payload helpers were also moved as the LRO helpers depend on them. The contents are directly copied from azcore with the exception of the Payload func which adds an options parameter. * fix up changelog
1 parent 375d773 commit 616b812

File tree

6 files changed

+510
-8
lines changed

6 files changed

+510
-8
lines changed

sdk/internal/CHANGELOG.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
# Release History
22

3-
## 1.2.1 (Unreleased)
3+
## 1.3.0 (2023-04-04)
44

55
### Features Added
6-
7-
### Breaking Changes
8-
9-
### Bugs Fixed
10-
11-
### Other Changes
6+
* Added package `poller` which exports various LRO helpers to aid in the creation of custom `PollerHandler[T]`.
7+
* Added package `exported` which contains payload helpers needed by the `poller` package and exported in `azcore`.
128

139
## 1.2.0 (2023-03-02)
1410

sdk/internal/exported/exported.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
// Copyright (c) Microsoft Corporation. All rights reserved.
5+
// Licensed under the MIT License.
6+
7+
package exported
8+
9+
import (
10+
"errors"
11+
"io"
12+
"net/http"
13+
)
14+
15+
// HasStatusCode returns true if the Response's status code is one of the specified values.
16+
// Exported as runtime.HasStatusCode().
17+
func HasStatusCode(resp *http.Response, statusCodes ...int) bool {
18+
if resp == nil {
19+
return false
20+
}
21+
for _, sc := range statusCodes {
22+
if resp.StatusCode == sc {
23+
return true
24+
}
25+
}
26+
return false
27+
}
28+
29+
// PayloadOptions contains the optional values for the Payload func.
30+
// NOT exported but used by azcore.
31+
type PayloadOptions struct {
32+
// BytesModifier receives the downloaded byte slice and returns an updated byte slice.
33+
// Use this to modify the downloaded bytes in a payload (e.g. removing a BOM).
34+
BytesModifier func([]byte) []byte
35+
}
36+
37+
// Payload reads and returns the response body or an error.
38+
// On a successful read, the response body is cached.
39+
// Subsequent reads will access the cached value.
40+
// Exported as runtime.Payload() WITHOUT the opts parameter.
41+
func Payload(resp *http.Response, opts *PayloadOptions) ([]byte, error) {
42+
modifyBytes := func(b []byte) []byte { return b }
43+
if opts != nil && opts.BytesModifier != nil {
44+
modifyBytes = opts.BytesModifier
45+
}
46+
47+
// r.Body won't be a nopClosingBytesReader if downloading was skipped
48+
if buf, ok := resp.Body.(*nopClosingBytesReader); ok {
49+
bytesBody := modifyBytes(buf.Bytes())
50+
buf.Set(bytesBody)
51+
return bytesBody, nil
52+
}
53+
54+
bytesBody, err := io.ReadAll(resp.Body)
55+
resp.Body.Close()
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
bytesBody = modifyBytes(bytesBody)
61+
resp.Body = &nopClosingBytesReader{s: bytesBody}
62+
return bytesBody, nil
63+
}
64+
65+
// PayloadDownloaded returns true if the response body has already been downloaded.
66+
// This implies that the Payload() func above has been previously called.
67+
// NOT exported but used by azcore.
68+
func PayloadDownloaded(resp *http.Response) bool {
69+
_, ok := resp.Body.(*nopClosingBytesReader)
70+
return ok
71+
}
72+
73+
// nopClosingBytesReader is an io.ReadSeekCloser around a byte slice.
74+
// It also provides direct access to the byte slice to avoid rereading.
75+
type nopClosingBytesReader struct {
76+
s []byte
77+
i int64
78+
}
79+
80+
// Bytes returns the underlying byte slice.
81+
func (r *nopClosingBytesReader) Bytes() []byte {
82+
return r.s
83+
}
84+
85+
// Close implements the io.Closer interface.
86+
func (*nopClosingBytesReader) Close() error {
87+
return nil
88+
}
89+
90+
// Read implements the io.Reader interface.
91+
func (r *nopClosingBytesReader) Read(b []byte) (n int, err error) {
92+
if r.i >= int64(len(r.s)) {
93+
return 0, io.EOF
94+
}
95+
n = copy(b, r.s[r.i:])
96+
r.i += int64(n)
97+
return
98+
}
99+
100+
// Set replaces the existing byte slice with the specified byte slice and resets the reader.
101+
func (r *nopClosingBytesReader) Set(b []byte) {
102+
r.s = b
103+
r.i = 0
104+
}
105+
106+
// Seek implements the io.Seeker interface.
107+
func (r *nopClosingBytesReader) Seek(offset int64, whence int) (int64, error) {
108+
var i int64
109+
switch whence {
110+
case io.SeekStart:
111+
i = offset
112+
case io.SeekCurrent:
113+
i = r.i + offset
114+
case io.SeekEnd:
115+
i = int64(len(r.s)) + offset
116+
default:
117+
return 0, errors.New("nopClosingBytesReader: invalid whence")
118+
}
119+
if i < 0 {
120+
return 0, errors.New("nopClosingBytesReader: negative position")
121+
}
122+
r.i = i
123+
return i, nil
124+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
// Copyright (c) Microsoft Corporation. All rights reserved.
5+
// Licensed under the MIT License.
6+
7+
package exported
8+
9+
import (
10+
"io"
11+
"net/http"
12+
"strings"
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestHasStatusCode(t *testing.T) {
19+
require.False(t, HasStatusCode(nil, http.StatusAccepted))
20+
require.False(t, HasStatusCode(&http.Response{}))
21+
require.False(t, HasStatusCode(&http.Response{StatusCode: http.StatusBadGateway}, http.StatusBadRequest))
22+
require.True(t, HasStatusCode(&http.Response{StatusCode: http.StatusOK}, http.StatusAccepted, http.StatusOK, http.StatusNoContent))
23+
}
24+
25+
func TestPayload(t *testing.T) {
26+
const val = "payload"
27+
resp := &http.Response{
28+
Body: io.NopCloser(strings.NewReader(val)),
29+
}
30+
b, err := Payload(resp, nil)
31+
require.NoError(t, err)
32+
if string(b) != val {
33+
t.Fatalf("got %s, want %s", string(b), val)
34+
}
35+
b, err = Payload(resp, nil)
36+
require.NoError(t, err)
37+
if string(b) != val {
38+
t.Fatalf("got %s, want %s", string(b), val)
39+
}
40+
}
41+
42+
func TestPayloadDownloaded(t *testing.T) {
43+
resp := &http.Response{
44+
Body: io.NopCloser(strings.NewReader("payload")),
45+
}
46+
require.False(t, PayloadDownloaded(resp))
47+
_, err := Payload(resp, nil)
48+
require.NoError(t, err)
49+
require.True(t, PayloadDownloaded((resp)))
50+
}
51+
52+
func TestPayloadBytesModifier(t *testing.T) {
53+
resp := &http.Response{
54+
Body: io.NopCloser(strings.NewReader("oldpayload")),
55+
}
56+
const newPayload = "newpayload"
57+
b, err := Payload(resp, &PayloadOptions{
58+
BytesModifier: func(b []byte) []byte { return []byte(newPayload) },
59+
})
60+
require.NoError(t, err)
61+
require.EqualValues(t, newPayload, string(b))
62+
}
63+
64+
func TestNopClosingBytesReader(t *testing.T) {
65+
const val1 = "the data"
66+
ncbr := &nopClosingBytesReader{s: []byte(val1)}
67+
require.NotNil(t, ncbr.Bytes())
68+
b, err := io.ReadAll(ncbr)
69+
require.NoError(t, err)
70+
require.EqualValues(t, val1, b)
71+
const val2 = "something else"
72+
ncbr.Set([]byte(val2))
73+
b, err = io.ReadAll(ncbr)
74+
require.NoError(t, err)
75+
require.EqualValues(t, val2, b)
76+
require.NoError(t, ncbr.Close())
77+
// seek to beginning and read again
78+
i, err := ncbr.Seek(0, io.SeekStart)
79+
require.NoError(t, err)
80+
require.Zero(t, i)
81+
b, err = io.ReadAll(ncbr)
82+
require.NoError(t, err)
83+
require.EqualValues(t, val2, b)
84+
// seek to middle from the end
85+
i, err = ncbr.Seek(-4, io.SeekEnd)
86+
require.NoError(t, err)
87+
require.EqualValues(t, i, len(val2)-4)
88+
b, err = io.ReadAll(ncbr)
89+
require.NoError(t, err)
90+
require.EqualValues(t, "else", b)
91+
// underflow
92+
_, err = ncbr.Seek(-int64(len(val2)+1), io.SeekCurrent)
93+
require.Error(t, err)
94+
}

sdk/internal/poller/util.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
// Copyright (c) Microsoft Corporation. All rights reserved.
5+
// Licensed under the MIT License.
6+
7+
package poller
8+
9+
import (
10+
"encoding/json"
11+
"errors"
12+
"fmt"
13+
"net/http"
14+
"net/url"
15+
"strings"
16+
17+
"github.com/Azure/azure-sdk-for-go/sdk/internal/exported"
18+
)
19+
20+
// the well-known set of LRO status/provisioning state values.
21+
const (
22+
StatusSucceeded = "Succeeded"
23+
StatusCanceled = "Canceled"
24+
StatusFailed = "Failed"
25+
StatusInProgress = "InProgress"
26+
)
27+
28+
// these are non-conformant states that we've seen in the wild.
29+
// we support them for back-compat.
30+
const (
31+
StatusCancelled = "Cancelled"
32+
StatusCompleted = "Completed"
33+
)
34+
35+
// IsTerminalState returns true if the LRO's state is terminal.
36+
func IsTerminalState(s string) bool {
37+
return Failed(s) || Succeeded(s)
38+
}
39+
40+
// Failed returns true if the LRO's state is terminal failure.
41+
func Failed(s string) bool {
42+
return strings.EqualFold(s, StatusFailed) || strings.EqualFold(s, StatusCanceled) || strings.EqualFold(s, StatusCancelled)
43+
}
44+
45+
// Succeeded returns true if the LRO's state is terminal success.
46+
func Succeeded(s string) bool {
47+
return strings.EqualFold(s, StatusSucceeded) || strings.EqualFold(s, StatusCompleted)
48+
}
49+
50+
// returns true if the LRO response contains a valid HTTP status code
51+
func StatusCodeValid(resp *http.Response) bool {
52+
return exported.HasStatusCode(resp, http.StatusOK, http.StatusAccepted, http.StatusCreated, http.StatusNoContent)
53+
}
54+
55+
// IsValidURL verifies that the URL is valid and absolute.
56+
func IsValidURL(s string) bool {
57+
u, err := url.Parse(s)
58+
return err == nil && u.IsAbs()
59+
}
60+
61+
// ErrNoBody is returned if the response didn't contain a body.
62+
var ErrNoBody = errors.New("the response did not contain a body")
63+
64+
// GetJSON reads the response body into a raw JSON object.
65+
// It returns ErrNoBody if there was no content.
66+
func GetJSON(resp *http.Response) (map[string]any, error) {
67+
body, err := exported.Payload(resp, nil)
68+
if err != nil {
69+
return nil, err
70+
}
71+
if len(body) == 0 {
72+
return nil, ErrNoBody
73+
}
74+
// unmarshall the body to get the value
75+
var jsonBody map[string]any
76+
if err = json.Unmarshal(body, &jsonBody); err != nil {
77+
return nil, err
78+
}
79+
return jsonBody, nil
80+
}
81+
82+
// provisioningState returns the provisioning state from the response or the empty string.
83+
func provisioningState(jsonBody map[string]any) string {
84+
jsonProps, ok := jsonBody["properties"]
85+
if !ok {
86+
return ""
87+
}
88+
props, ok := jsonProps.(map[string]any)
89+
if !ok {
90+
return ""
91+
}
92+
rawPs, ok := props["provisioningState"]
93+
if !ok {
94+
return ""
95+
}
96+
ps, ok := rawPs.(string)
97+
if !ok {
98+
return ""
99+
}
100+
return ps
101+
}
102+
103+
// status returns the status from the response or the empty string.
104+
func status(jsonBody map[string]any) string {
105+
rawStatus, ok := jsonBody["status"]
106+
if !ok {
107+
return ""
108+
}
109+
status, ok := rawStatus.(string)
110+
if !ok {
111+
return ""
112+
}
113+
return status
114+
}
115+
116+
// GetStatus returns the LRO's status from the response body.
117+
// Typically used for Azure-AsyncOperation flows.
118+
// If there is no status in the response body the empty string is returned.
119+
func GetStatus(resp *http.Response) (string, error) {
120+
jsonBody, err := GetJSON(resp)
121+
if err != nil {
122+
return "", err
123+
}
124+
return status(jsonBody), nil
125+
}
126+
127+
// GetProvisioningState returns the LRO's state from the response body.
128+
// If there is no state in the response body the empty string is returned.
129+
func GetProvisioningState(resp *http.Response) (string, error) {
130+
jsonBody, err := GetJSON(resp)
131+
if err != nil {
132+
return "", err
133+
}
134+
return provisioningState(jsonBody), nil
135+
}
136+
137+
// GetResourceLocation returns the LRO's resourceLocation value from the response body.
138+
// Typically used for Operation-Location flows.
139+
// If there is no resourceLocation in the response body the empty string is returned.
140+
func GetResourceLocation(resp *http.Response) (string, error) {
141+
jsonBody, err := GetJSON(resp)
142+
if err != nil {
143+
return "", err
144+
}
145+
v, ok := jsonBody["resourceLocation"]
146+
if !ok {
147+
// it might be ok if the field doesn't exist, the caller must make that determination
148+
return "", nil
149+
}
150+
vv, ok := v.(string)
151+
if !ok {
152+
return "", fmt.Errorf("the resourceLocation value %v was not in string format", v)
153+
}
154+
return vv, nil
155+
}

0 commit comments

Comments
 (0)