Skip to content

Commit

Permalink
feat: Automatically populate uuid4 fields
Browse files Browse the repository at this point in the history
  • Loading branch information
parthea committed Mar 19, 2024
1 parent 24a23a1 commit 03cc1e1
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ from collections import OrderedDict
import os
import re
from typing import Callable, Dict, Mapping, MutableMapping, MutableSequence, Optional, {% if service.any_server_streaming %}Iterable, {% endif %}{% if service.any_client_streaming %}Iterator, {% endif %}Sequence, Tuple, Type, Union, cast
import uuid
{% if service.any_deprecated %}
import warnings
{% endif %}
Expand Down Expand Up @@ -473,6 +474,27 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
)
{% endif %}

{#
Automatically populate uuid4 fields according to AIP-4235
(https://google.aip.dev/client-libraries/4235) if the
field satisfies either of:
- The field supports explicit presence and has not been set by the user
- The field doesn't support explicit presence, and its value is the empty
string (i.e. the default value)
#}
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
{% if method.input.fields[auto_populated_field].proto3_optional %}
if not request.HasField({{ auto_populated_field }}):
{% else %}
if not request.{{ auto_populated_field }}:
{% endif %}
request.{{ auto_populated_field }} = str(uuid.uuid4())
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}

# Send the request.
{%+ if not method.void %}response = {% endif %}rpc(
{% if not method.client_streaming %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{% block content %}

import os
import re
# try/except added for compatibility with python < 3.8
try:
from unittest import mock
Expand Down Expand Up @@ -519,8 +520,20 @@ def test_{{ method_name }}(request_type, transport: str = 'grpc'):
)

# Everything is optional in proto3 as far as the runtime is concerned,
# and we are mocking out the actual API, so just send an empty request.
# and we are mocking out the actual API.
request = request_type()

# Set uuid4 fields so that they are not automatically popoulated.
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
if isinstance(request, dict):
request['{{ auto_populated_field }}'] = "str_value"
else:
request.{{ auto_populated_field }} = "str_value"
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
{% if method.client_streaming %}
requests = [request]
{% endif %}
Expand Down Expand Up @@ -629,6 +642,16 @@ def test_{{ method_name }}_empty_call():
{% if method.client_streaming %}
assert next(args[0]) == request
{% else %}
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
# Ensure that the uuid4 field is set according to AIP 4235
assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", args[0].{{ auto_populated_field }})
# clear UUID field so that the check below succeeds
args[0].{{ auto_populated_field }} = ""
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
assert args[0] == {{ method.input.ident }}()
{% endif %}
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@
)
{% endif %} {# method.explicit_routing #}

{{ auto_populate_uuid4_fields(api, method) }}

# Validate the universe domain.
self._validate_universe_domain()

Expand Down Expand Up @@ -265,3 +267,26 @@

{% macro define_extended_operation_subclass(extended_operation) %}
{% endmacro %}

{% macro auto_populate_uuid4_fields(api, method) %}
{#
Automatically populate uuid4 fields according to AIP-4235
(https://google.aip.dev/client-libraries/4235) if the
field satisfies either of:
- The field supports explicit presence and has not been set by the user
- The field doesn't support explicit presence, and its value is the empty
string (i.e. the default value)
#}
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
{% if method.input.fields[auto_populated_field].proto3_optional %}
if not request.HasField({{ auto_populated_field }}):
{% else %}
if not request.{{ auto_populated_field }}:
{% endif %}
request.{{ auto_populated_field }} = str(uuid.uuid4())
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
{% endmacro %}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{% extends "_base.py.j2" %}

{% block content %}
{% import "%namespace/%name_%version/%sub/services/%service/_client_macros.j2" as macros %}

from collections import OrderedDict
import functools
import re
from typing import Dict, Mapping, MutableMapping, MutableSequence, Optional, {% if service.any_server_streaming %}AsyncIterable, Awaitable, {% endif %}{% if service.any_client_streaming %}AsyncIterator, {% endif %}Sequence, Tuple, Type, Union
import uuid
{% if service.any_deprecated %}
import warnings
{% endif %}
Expand Down Expand Up @@ -386,6 +388,8 @@ class {{ service.async_client_name }}:
)
{% endif %}

{{ macros.auto_populate_uuid4_fields(api, method) }}

# Validate the universe domain.
self._client._validate_universe_domain()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import functools
import os
import re
from typing import Dict, Mapping, MutableMapping, MutableSequence, Optional, {% if service.any_server_streaming %}Iterable, {% endif %}{% if service.any_client_streaming %}Iterator, {% endif %}Sequence, Tuple, Type, Union, cast
import uuid
import warnings

{% set package_path = api.naming.module_namespace|join('.') + "." + api.naming.versioned_module_name %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
{% import "tests/unit/gapic/%name_%version/%sub/test_macros.j2" as test_macros %}

import os
import re
# try/except added for compatibility with python < 3.8
try:
from unittest import mock
Expand Down Expand Up @@ -849,10 +850,10 @@ def test_{{ service.client_name|snake_case }}_create_channel_credentials_file(cl

{% for method in service.methods.values() if 'grpc' in opts.transport %}{# method_name #}
{% if method.extended_lro %}
{{ test_macros.grpc_required_tests(method, service, full_extended_lro=True) }}
{{ test_macros.grpc_required_tests(method, service, api, full_extended_lro=True) }}

{% endif %}
{{ test_macros.grpc_required_tests(method, service) }}
{{ test_macros.grpc_required_tests(method, service, api) }}
{% endfor %} {# method in methods for grpc #}

{% for method in service.methods.values() if 'rest' in opts.transport %}
Expand Down
114 changes: 109 additions & 5 deletions gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% macro grpc_required_tests(method, service, full_extended_lro=False) %}
{% macro grpc_required_tests(method, service, api, full_extended_lro=False) %}
{% with method_name = method.safe_name|snake_case + "_unary" if method.extended_lro and not full_extended_lro else method.safe_name|snake_case, method_output = method.extended_lro.operation_type if method.extended_lro and not full_extended_lro else method.output %}
@pytest.mark.parametrize("request_type", [
{{ method.input.ident }},
Expand All @@ -11,8 +11,19 @@ def test_{{ method_name }}(request_type, transport: str = 'grpc'):
)

# Everything is optional in proto3 as far as the runtime is concerned,
# and we are mocking out the actual API, so just send an empty request.
# and we are mocking out the actual API.
request = request_type()
# Set uuid4 fields so that they are not automatically popoulated.
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
if isinstance(request, dict):
request['{{ auto_populated_field }}'] = "str_value"
else:
request.{{ auto_populated_field }} = "str_value"
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
{% if method.client_streaming %}
requests = [request]
{% endif %}
Expand Down Expand Up @@ -58,7 +69,15 @@ def test_{{ method_name }}(request_type, transport: str = 'grpc'):
{% if method.client_streaming %}
assert next(args[0]) == request
{% else %}
assert args[0] == {{ method.input.ident }}()
request = {{ method.input.ident }}()
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
request.{{ auto_populated_field }} = "str_value"
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
assert args[0] == request
{% endif %}

# Establish that the response is the type that we expect.
Expand Down Expand Up @@ -119,11 +138,77 @@ def test_{{ method_name }}_empty_call():
{% if method.client_streaming %}
assert next(args[0]) == request
{% else %}
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
# Ensure that the uuid4 field is set according to AIP 4235
assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", args[0].{{ auto_populated_field }})
# clear UUID field so that the check below succeeds
args[0].{{ auto_populated_field }} = ""
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
assert args[0] == {{ method.input.ident }}()
{% endif %}
{% endif %}

{% if not full_extended_lro %}
{% if not method.client_streaming %}
@pytest.mark.asyncio
async def test_{{ method_name }}_empty_call_async():
# This test is a coverage failsafe to make sure that totally empty calls,
# i.e. request == None and no flattened fields passed, work.
client = {{ service.async_client_name }}(
credentials=ga_credentials.AnonymousCredentials(),
transport='grpc_asyncio',
)

# Mock the actual call within the gRPC stub, and fake the request.
with mock.patch.object(
type(client.transport.{{ method.transport_safe_name|snake_case }}),
'__call__') as call:
# Designate an appropriate return value for the call.
{% if method.void %}
call.return_value = grpc_helpers_async.FakeUnaryUnaryCall(None)
{% elif method.lro %}
call.return_value = grpc_helpers_async.FakeUnaryUnaryCall(
operations_pb2.Operation(name='operations/spam')
)
{% elif not method.client_streaming and method.server_streaming %}
call.return_value = mock.Mock(aio.UnaryStreamCall, autospec=True)
call.return_value.read = mock.AsyncMock(side_effect=[{{ method.output.ident }}()])
{% elif method.client_streaming and method.server_streaming %}
call.return_value = mock.Mock(aio.StreamStreamCall, autospec=True)
call.return_value.read = mock.AsyncMock(side_effect=[{{ method.output.ident }}()])
{% else %}
call.return_value ={{ '' }}
{%- if not method.client_streaming and not method.server_streaming -%}
grpc_helpers_async.FakeUnaryUnaryCall
{%- else -%}
grpc_helpers_async.FakeStreamUnaryCall
{%- endif -%}({{ method.output.ident }}(
{% for field in method.output.fields.values() | rejectattr('message') %}{% if not field.oneof or field.proto3_optional %}
{{ field.name }}={{ field.mock_value }},
{% endif %}
{% endfor %}
))
{% endif %}
response = await client.{{ method_name }}()
call.assert_called()
_, args, _ = call.mock_calls[0]
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
# Ensure that the uuid4 field is set according to AIP 4235
assert re.match(r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}", args[0].{{ auto_populated_field }})
# clear UUID field so that the check below succeeds
args[0].{{ auto_populated_field }} = ""
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
assert args[0] == {{ method.input.ident }}()
{% endif %}

@pytest.mark.asyncio
async def test_{{ method_name }}_async(transport: str = 'grpc_asyncio', request_type={{ method.input.ident }}):
client = {{ service.async_client_name }}(
Expand All @@ -132,8 +217,19 @@ async def test_{{ method_name }}_async(transport: str = 'grpc_asyncio', request_
)

# Everything is optional in proto3 as far as the runtime is concerned,
# and we are mocking out the actual API, so just send an empty request.
# and we are mocking out the actual API.
request = request_type()
# Set uuid4 fields so that they are not automatically popoulated.
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
if isinstance(request, dict):
request['{{ auto_populated_field }}'] = "str_value"
else:
request.{{ auto_populated_field }} = "str_value"
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
{% if method.client_streaming %}
requests = [request]
{% endif %}
Expand Down Expand Up @@ -182,7 +278,15 @@ async def test_{{ method_name }}_async(transport: str = 'grpc_asyncio', request_
{% if method.client_streaming %}
assert next(args[0]) == request
{% else %}
assert args[0] == {{ method.input.ident }}()
request = {{ method.input.ident }}()
{% with method_settings = api.all_method_settings.get(method.meta.address.proto) %}
{% if method_settings is not none %}
{% for auto_populated_field in method_settings.auto_populated_fields %}
request.{{ auto_populated_field }} = "str_value"
{% endfor %}
{% endif %}{# if method_settings is not none #}
{% endwith %}{# method_settings #}
assert args[0] == request
{% endif %}

# Establish that the response is the type that we expect.
Expand Down
34 changes: 34 additions & 0 deletions tests/system/test_unary.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import os
import pytest
import re

from google.api_core import exceptions
from google.rpc import code_pb2
Expand All @@ -24,15 +25,48 @@
def test_unary_with_request_object(echo):
response = echo.echo(showcase.EchoRequest(
content='The hail in Wales falls mainly on the snails.',
request_id='some_value',
))
assert response.content == 'The hail in Wales falls mainly on the snails.'
assert response.request_id == 'some_value'

# Repeat the same test but this time without `request_id`` set
# The `request_id` field should be automatically populated with
# a UUID4 value if it is not set.
# See https://google.aip.dev/client-libraries/4235
response = echo.echo(showcase.EchoRequest(
content='The hail in Wales falls mainly on the snails.',
))
assert response.content == 'The hail in Wales falls mainly on the snails.'
# Ensure that the uuid4 field is set according to AIP 4235
re.match(
r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}",
response.request_id,
)
assert len(response.request_id) == 36


def test_unary_with_dict(echo):
response = echo.echo({
'content': 'The hail in Wales falls mainly on the snails.',
'request_id': 'some_value',
})
assert response.content == 'The hail in Wales falls mainly on the snails.'
assert response.request_id == 'some_value'

# Repeat the same test but this time without `request_id`` set
# The `request_id` field should be automatically populated with
# a UUID4 value if it is not set.
# See https://google.aip.dev/client-libraries/4235
response = echo.echo({
'content': 'The hail in Wales falls mainly on the snails.',
})
assert response.content == 'The hail in Wales falls mainly on the snails.'
re.match(
r"[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}",
response.request_id,
)
assert len(response.request_id) == 36


def test_unary_error(echo):
Expand Down

0 comments on commit 03cc1e1

Please sign in to comment.