Skip to content

Commit fba4c81

Browse files
authored
[core] accept 'final-state-via' lro_options in base polling (Azure#22713)
1 parent bc204ce commit fba4c81

File tree

5 files changed

+303
-4
lines changed

5 files changed

+303
-4
lines changed

sdk/core/azure-core/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Add support for `final-state-via` LRO option in core. #22713
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/core/azure-core/azure/core/polling/base_polling.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import abc
2727
import base64
2828
import json
29+
from enum import Enum
2930
from typing import TYPE_CHECKING, Optional, Any, Union
3031

3132
from ..exceptions import HttpResponseError, DecodeError
@@ -174,20 +175,38 @@ def get_final_get_url(self, pipeline_response):
174175
"""
175176
raise NotImplementedError()
176177

178+
class _LroOption(str, Enum):
179+
"""Known LRO options from Swagger."""
180+
181+
FINAL_STATE_VIA = "final-state-via"
182+
183+
184+
class _FinalStateViaOption(str, Enum):
185+
"""Possible final-state-via options."""
186+
187+
AZURE_ASYNC_OPERATION_FINAL_STATE = "azure-async-operation"
188+
LOCATION_FINAL_STATE = "location"
189+
OPERATION_LOCATION_FINAL_STATE = "operation-location"
190+
177191

178192
class OperationResourcePolling(LongRunningOperation):
179193
"""Implements a operation resource polling, typically from Operation-Location.
180194
181195
:param str operation_location_header: Name of the header to return operation format (default 'operation-location')
196+
:keyword dict[str, any] lro_options: Additional options for LRO. For more information, see
197+
https://aka.ms/azsdk/autorest/openapi/lro-options
182198
"""
183199

184-
def __init__(self, operation_location_header="operation-location"):
200+
def __init__(
201+
self, operation_location_header="operation-location", **kwargs
202+
):
185203
self._operation_location_header = operation_location_header
186204

187205
# Store the initial URLs
188206
self._async_url = None
189207
self._location_url = None
190208
self._request = None
209+
self._lro_options = kwargs.pop("lro_options", {}) or {}
191210

192211
def can_poll(self, pipeline_response):
193212
"""Answer if this polling method could be used.
@@ -207,6 +226,19 @@ def get_final_get_url(self, pipeline_response):
207226
208227
:rtype: str
209228
"""
229+
if (
230+
self._lro_options.get(_LroOption.FINAL_STATE_VIA) == _FinalStateViaOption.LOCATION_FINAL_STATE
231+
and self._location_url
232+
):
233+
return self._location_url
234+
if (
235+
self._lro_options.get(_LroOption.FINAL_STATE_VIA)
236+
in [
237+
_FinalStateViaOption.AZURE_ASYNC_OPERATION_FINAL_STATE,
238+
_FinalStateViaOption.OPERATION_LOCATION_FINAL_STATE
239+
]
240+
):
241+
return None
210242
response = pipeline_response.http_response
211243
if not _is_empty(response):
212244
body = _as_json(response)
@@ -381,7 +413,7 @@ def __init__(
381413
**operation_config
382414
):
383415
self._lro_algorithms = lro_algorithms or [
384-
OperationResourcePolling(),
416+
OperationResourcePolling(lro_options=lro_options),
385417
LocationPolling(),
386418
StatusCheckPolling(),
387419
]

sdk/core/azure-core/tests/async_tests/test_base_polling_async.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242

4343
from msrest import Deserializer
4444

45-
from azure.core.polling import async_poller
45+
from azure.core.polling import async_poller, AsyncLROPoller
4646
from azure.core.exceptions import DecodeError, HttpResponseError
4747
from azure.core import AsyncPipelineClient
4848
from azure.core.pipeline import PipelineResponse, AsyncPipeline, PipelineContext
@@ -52,6 +52,7 @@
5252
AsyncLROBasePolling,
5353
)
5454
from utils import ASYNCIO_REQUESTS_TRANSPORT_RESPONSES, request_and_responses_product, create_transport_response
55+
from rest_client_async import AsyncTestRestClient
5556

5657
class SimpleResource:
5758
"""An implementation of Python 3 SimpleNamespace.
@@ -748,3 +749,125 @@ async def test_long_running_negative(http_request, http_response):
748749
LOCATION_BODY = json.dumps({ 'name': TEST_NAME })
749750
POLLING_STATUS = 200
750751

752+
@pytest.mark.asyncio
753+
@pytest.mark.parametrize("http_request,http_response", request_and_responses_product(ASYNCIO_REQUESTS_TRANSPORT_RESPONSES))
754+
async def test_post_final_state_via(async_pipeline_client_builder, deserialization_cb, http_request, http_response):
755+
# Test POST LRO with both Location and Operation-Location
756+
CLIENT.http_request_type = http_request
757+
CLIENT.http_response_type = http_response
758+
# The initial response contains both Location and Operation-Location, a 202 and no Body
759+
initial_response = TestBasePolling.mock_send(
760+
http_request,
761+
http_response,
762+
'POST',
763+
202,
764+
{
765+
'location': 'http://example.org/location',
766+
'operation-location': 'http://example.org/async_monitor',
767+
},
768+
''
769+
)
770+
771+
async def send(request, **kwargs):
772+
assert request.method == 'GET'
773+
774+
if request.url == 'http://example.org/location':
775+
return TestBasePolling.mock_send(
776+
http_request,
777+
http_response,
778+
'GET',
779+
200,
780+
body={'location_result': True}
781+
).http_response
782+
elif request.url == 'http://example.org/async_monitor':
783+
return TestBasePolling.mock_send(
784+
http_request,
785+
http_response,
786+
'GET',
787+
200,
788+
body={'status': 'Succeeded'}
789+
).http_response
790+
else:
791+
pytest.fail("No other query allowed")
792+
793+
client = async_pipeline_client_builder(send)
794+
795+
# Test 1, LRO options with Location final state
796+
poll = async_poller(
797+
client,
798+
initial_response,
799+
deserialization_cb,
800+
AsyncLROBasePolling(0, lro_options={"final-state-via": "location"}))
801+
result = await poll
802+
assert result['location_result'] == True
803+
804+
# Test 2, LRO options with Operation-Location final state
805+
poll = async_poller(
806+
client,
807+
initial_response,
808+
deserialization_cb,
809+
AsyncLROBasePolling(0, lro_options={"final-state-via": "operation-location"}))
810+
result = await poll
811+
assert result['status'] == 'Succeeded'
812+
813+
# Test 3, "do the right thing" and use Location by default
814+
poll = async_poller(
815+
client,
816+
initial_response,
817+
deserialization_cb,
818+
AsyncLROBasePolling(0))
819+
result = await poll
820+
assert result['location_result'] == True
821+
822+
# Test 4, location has no body
823+
824+
async def send(request, **kwargs):
825+
assert request.method == 'GET'
826+
827+
if request.url == 'http://example.org/location':
828+
return TestBasePolling.mock_send(
829+
http_request,
830+
http_response,
831+
'GET',
832+
200,
833+
body=None
834+
).http_response
835+
elif request.url == 'http://example.org/async_monitor':
836+
return TestBasePolling.mock_send(
837+
http_request,
838+
http_response,
839+
'GET',
840+
200,
841+
body={'status': 'Succeeded'}
842+
).http_response
843+
else:
844+
pytest.fail("No other query allowed")
845+
846+
client = async_pipeline_client_builder(send)
847+
848+
poll = async_poller(
849+
client,
850+
initial_response,
851+
deserialization_cb,
852+
AsyncLROBasePolling(0, lro_options={"final-state-via": "location"}))
853+
result = await poll
854+
assert result is None
855+
856+
@pytest.mark.asyncio
857+
@pytest.mark.parametrize("http_request", HTTP_REQUESTS)
858+
async def test_final_get_via_location(port, http_request, deserialization_cb):
859+
client = AsyncTestRestClient(port)
860+
request = http_request(
861+
"PUT",
862+
"http://localhost:{}/polling/polling-with-options".format(port),
863+
)
864+
request.set_json_body({"hello": "world!"})
865+
initial_response = await client._client._pipeline.run(request)
866+
poller = AsyncLROPoller(
867+
client._client,
868+
initial_response,
869+
deserialization_cb,
870+
AsyncLROBasePolling(0, lro_options={"final-state-via": "location"}),
871+
)
872+
result = await poller.result()
873+
assert result == {"returnedFrom": "locationHeaderUrl"}

sdk/core/azure-core/tests/test_base_polling.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import pickle
3232
import platform
3333
import six
34+
3435
try:
3536
from unittest import mock
3637
except ImportError:
@@ -48,8 +49,9 @@
4849

4950
from azure.core.polling.base_polling import LROBasePolling
5051
from azure.core.pipeline.policies._utils import _FixedOffset
51-
from utils import request_and_responses_product, REQUESTS_TRANSPORT_RESPONSES, create_transport_response
52+
from utils import request_and_responses_product, REQUESTS_TRANSPORT_RESPONSES, create_transport_response, HTTP_REQUESTS
5253
from azure.core.pipeline._tools import is_rest
54+
from rest_client import TestRestClient
5355

5456
class SimpleResource:
5557
"""An implementation of Python 3 SimpleNamespace.
@@ -756,3 +758,124 @@ def test_long_running_negative(self, http_request, http_response):
756758

757759
LOCATION_BODY = json.dumps({ 'name': TEST_NAME })
758760
POLLING_STATUS = 200
761+
762+
@pytest.mark.parametrize("http_request,http_response", request_and_responses_product(REQUESTS_TRANSPORT_RESPONSES))
763+
def test_post_final_state_via(self, pipeline_client_builder, deserialization_cb, http_request, http_response):
764+
# Test POST LRO with both Location and Operation-Location
765+
CLIENT.http_request_type = http_request
766+
CLIENT.http_response_type = http_response
767+
# The initial response contains both Location and Operation-Location, a 202 and no Body
768+
initial_response = TestBasePolling.mock_send(
769+
http_request,
770+
http_response,
771+
'POST',
772+
202,
773+
{
774+
'location': 'http://example.org/location',
775+
'operation-location': 'http://example.org/async_monitor',
776+
},
777+
''
778+
)
779+
780+
def send(request, **kwargs):
781+
assert request.method == 'GET'
782+
783+
if request.url == 'http://example.org/location':
784+
return TestBasePolling.mock_send(
785+
http_request,
786+
http_response,
787+
'GET',
788+
200,
789+
body={'location_result': True}
790+
).http_response
791+
elif request.url == 'http://example.org/async_monitor':
792+
return TestBasePolling.mock_send(
793+
http_request,
794+
http_response,
795+
'GET',
796+
200,
797+
body={'status': 'Succeeded'}
798+
).http_response
799+
else:
800+
pytest.fail("No other query allowed")
801+
802+
client = pipeline_client_builder(send)
803+
804+
# Test 1, LRO options with Location final state
805+
poll = LROPoller(
806+
client,
807+
initial_response,
808+
deserialization_cb,
809+
LROBasePolling(0, lro_options={"final-state-via": "location"}))
810+
result = poll.result()
811+
assert result['location_result'] == True
812+
813+
# Test 2, LRO options with Operation-Location final state
814+
poll = LROPoller(
815+
client,
816+
initial_response,
817+
deserialization_cb,
818+
LROBasePolling(0, lro_options={"final-state-via": "operation-location"}))
819+
result = poll.result()
820+
assert result['status'] == 'Succeeded'
821+
822+
# Test 3, "do the right thing" and use Location by default
823+
poll = LROPoller(
824+
client,
825+
initial_response,
826+
deserialization_cb,
827+
LROBasePolling(0))
828+
result = poll.result()
829+
assert result['location_result'] == True
830+
831+
# Test 4, location has no body
832+
833+
def send(request, **kwargs):
834+
assert request.method == 'GET'
835+
836+
if request.url == 'http://example.org/location':
837+
return TestBasePolling.mock_send(
838+
http_request,
839+
http_response,
840+
'GET',
841+
200,
842+
body=None
843+
).http_response
844+
elif request.url == 'http://example.org/async_monitor':
845+
return TestBasePolling.mock_send(
846+
http_request,
847+
http_response,
848+
'GET',
849+
200,
850+
body={'status': 'Succeeded'}
851+
).http_response
852+
else:
853+
pytest.fail("No other query allowed")
854+
855+
client = pipeline_client_builder(send)
856+
857+
poll = LROPoller(
858+
client,
859+
initial_response,
860+
deserialization_cb,
861+
LROBasePolling(0, lro_options={"final-state-via": "location"}))
862+
result = poll.result()
863+
assert result is None
864+
865+
@pytest.mark.parametrize("http_request", HTTP_REQUESTS)
866+
def test_final_get_via_location(port, http_request, deserialization_cb):
867+
client = TestRestClient(port)
868+
request = http_request(
869+
"PUT",
870+
"http://localhost:{}/polling/polling-with-options".format(port),
871+
)
872+
request.set_json_body({"hello": "world!"})
873+
initial_response = client._client._pipeline.run(request)
874+
poller = LROPoller(
875+
client._client,
876+
initial_response,
877+
deserialization_cb,
878+
LROBasePolling(0, lro_options={"final-state-via": "location"}),
879+
)
880+
result = poller.result()
881+
assert result == {"returnedFrom": "locationHeaderUrl"}

sdk/core/azure-core/tests/testserver_tests/coretestserver/coretestserver/test_routes/polling.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,22 @@ def request_id_location():
151151
'{"status": "Succeeded"}',
152152
status=200
153153
)
154+
155+
@polling_api.route('/polling-with-options', methods=["PUT"])
156+
def polling_with_options_first():
157+
base_url = get_base_url(request)
158+
return Response(
159+
'{"properties":{"provisioningState": "InProgress"}}',
160+
headers={
161+
'location': '{}/polling/final-get-with-location'.format(base_url),
162+
'operation-location': '{}/polling/post/resource-location/operation-location-url'.format(base_url),
163+
},
164+
status=202
165+
)
166+
167+
@polling_api.route('/final-get-with-location', methods=["GET"])
168+
def polling_with_options_final_get_with_location():
169+
return Response(
170+
'{"returnedFrom": "locationHeaderUrl"}',
171+
status=200
172+
)

0 commit comments

Comments
 (0)