Skip to content

Commit 635d4e6

Browse files
feat(error-template-support): add support for response error templates (#25)
This commit bears the support for response error templates which are resolved at runtime in the response handler class. Other than the feature, this commit contains unit tests, a fix for the default case at the endpoint level along with some refactoring of the existing code in error case and response handler classes. closes #24
1 parent 7a1fa2d commit 635d4e6

8 files changed

Lines changed: 307 additions & 37 deletions

File tree

apimatic_core/response_handler.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1+
import re
12
from apimatic_core.http.response.api_response import ApiResponse
23
from apimatic_core.types.error_case import ErrorCase
34

45

56
class ResponseHandler:
67

7-
def __init__(
8-
self
9-
):
8+
def __init__(self):
109
self._deserializer = None
1110
self._convertor = None
1211
self._deserialize_into = None
@@ -39,8 +38,16 @@ def is_nullify404(self, is_nullify404):
3938
self._is_nullify404 = is_nullify404
4039
return self
4140

42-
def local_error(self, error_code, description, exception_type):
43-
self._local_errors[str(error_code)] = ErrorCase().description(description).exception_type(exception_type)
41+
def local_error(self, error_code, error_message, exception_type):
42+
self._local_errors[str(error_code)] = ErrorCase()\
43+
.error_message(error_message)\
44+
.exception_type(exception_type)
45+
return self
46+
47+
def local_error_template(self, error_code, error_message_template, exception_type):
48+
self._local_errors[str(error_code)] = ErrorCase()\
49+
.error_message_template(error_message_template)\
50+
.exception_type(exception_type)
4451
return self
4552

4653
def datetime_format(self, datetime_format):
@@ -87,20 +94,12 @@ def handle(self, response, global_errors):
8794
return deserialized_value
8895

8996
def validate(self, response, global_errors):
90-
actual_status_code = str(response.status_code)
91-
if self._local_errors:
92-
for expected_status_code, error_case in self._local_errors.items():
93-
if actual_status_code == expected_status_code:
94-
raise error_case.get_exception_type()(error_case.get_description(), response)
97+
if response.status_code in range(200, 300):
98+
return
9599

96-
if global_errors:
97-
for expected_status_code, error_case in global_errors.items():
98-
if actual_status_code == expected_status_code:
99-
raise error_case.get_exception_type()(error_case.get_description(), response)
100+
self.validate_against_error_cases(response, self._local_errors)
100101

101-
if (response.status_code < 200 or response.status_code > 208) and global_errors.get('default'):
102-
error_case = global_errors['default']
103-
raise error_case.get_exception_type()(error_case.get_description(), response)
102+
self.validate_against_error_cases(response, global_errors)
104103

105104
def apply_xml_deserializer(self, response):
106105
if self._xml_item_name:
@@ -131,3 +130,23 @@ def apply_convertor(self, deserialized_value):
131130
return self._convertor(deserialized_value)
132131

133132
return deserialized_value
133+
134+
@staticmethod
135+
def validate_against_error_cases(response, error_cases):
136+
actual_status_code = str(response.status_code)
137+
# Handling error case when configured as explicit error code
138+
error_case = error_cases.get(actual_status_code) if error_cases else None
139+
if error_case:
140+
error_case.raise_exception(response)
141+
142+
# Handling error case when configured as explicit error codes range
143+
default_range_error_case = [error_cases[status_code] for status_code, error_case in error_cases.items()
144+
if re.match(r'^[{}]XX$'.format(actual_status_code[0]),
145+
status_code)] if error_cases else None
146+
if default_range_error_case:
147+
default_range_error_case[0].raise_exception(response)
148+
149+
# Handling default error case if configured
150+
default_error_case = error_cases.get('default') if error_cases else None
151+
if default_error_case:
152+
default_error_case.raise_exception(response)

apimatic_core/types/error_case.py

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,94 @@
1+
import re
2+
from apimatic_core.utilities.api_helper import ApiHelper
3+
14

25
class ErrorCase:
36

4-
def get_description(self):
5-
return self._description
7+
def is_error_message_template(self):
8+
"""Checks if the set exception message is a template or not.
69
7-
def get_exception_type(self):
8-
return self._exception_type
10+
Returns:
11+
string: True if the exception message is a template.
12+
"""
13+
return True if self._error_message_template else False
914

10-
def __init__(
11-
self
12-
):
13-
self._description = None
15+
def __init__(self):
16+
self._error_message = None
17+
self._error_message_template = None
1418
self._exception_type = None
1519

16-
def description(self, description):
17-
self._description = description
20+
def error_message(self, error_message):
21+
"""Setter for the error message.
22+
Args:
23+
error_message: The simple exception message.
24+
"""
25+
self._error_message = error_message
26+
return self
27+
28+
def error_message_template(self, error_message_template):
29+
"""Setter for the error message template.
30+
Args:
31+
error_message_template: The exception message template containing placeholders.
32+
"""
33+
self._error_message_template = error_message_template
1834
return self
1935

2036
def exception_type(self, exception_type):
37+
"""Setter for the exception type.
38+
Args:
39+
exception_type: The exception type to raise.
40+
"""
2141
self._exception_type = exception_type
2242
return self
43+
44+
def get_error_message(self, response):
45+
"""Getter for the error message for the exception case. This considers both error message
46+
and error template message. Error message template has the higher precedence over an error message.
47+
Args:
48+
response: The received http response.
49+
50+
Returns:
51+
string: The resolved exception message.
52+
"""
53+
if self.is_error_message_template():
54+
return self._get_resolved_error_message_template(response)
55+
return self._error_message
56+
57+
def raise_exception(self, response):
58+
"""Raises the exception for the current error case type.
59+
Args:
60+
response: The received http response.
61+
"""
62+
raise self._exception_type(self.get_error_message(response), response)
63+
64+
def _get_resolved_error_message_template(self, response):
65+
"""Updates all placeholders in the given message template with provided value.
66+
67+
Args:
68+
response: The received http response.
69+
70+
Returns:
71+
string: The resolved template value.
72+
"""
73+
placeholders = re.findall(r'\{\$.*?\}', self._error_message_template)
74+
75+
status_code_placeholder = set(filter(lambda element: element == '{$statusCode}', placeholders))
76+
header_placeholders = set(filter(lambda element: element.startswith('{$response.header'), placeholders))
77+
body_placeholders = set(filter(lambda element: element.startswith('{$response.body'), placeholders))
78+
79+
# Handling response code placeholder
80+
error_message_template = ApiHelper.resolve_template_placeholders(status_code_placeholder,
81+
str(response.status_code),
82+
self._error_message_template)
83+
84+
# Handling response header placeholder
85+
error_message_template = ApiHelper.resolve_template_placeholders(header_placeholders, response.headers,
86+
error_message_template)
87+
88+
# Handling response body placeholder
89+
response_payload = ApiHelper.json_deserialize(response.text, as_dict=True)
90+
error_message_template = ApiHelper.resolve_template_placeholders_using_json_pointer(body_placeholders,
91+
response_payload,
92+
error_message_template)
93+
94+
return error_message_template

apimatic_core/utilities/api_helper.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# -*- coding: utf-8 -*-
2-
3-
2+
from collections import abc
43
import re
5-
import sys
64
import datetime
75
import calendar
86
import email.utils as eut
97
from time import mktime
108

119
import jsonpickle
1210
import dateutil.parser
11+
from jsonpointer import JsonPointerException, resolve_pointer
12+
1313
from apimatic_core.types.datetime_format import DateTimeFormat
1414
from apimatic_core.types.file_wrapper import FileWrapper
1515
from apimatic_core.types.array_serialization_format import SerializationFormats
@@ -516,6 +516,61 @@ def when_defined(func, value):
516516
def is_file_wrapper_instance(param):
517517
return isinstance(param, FileWrapper)
518518

519+
@staticmethod
520+
def resolve_template_placeholders_using_json_pointer(placeholders, value, template):
521+
"""Updates all placeholders in the given message template with provided value.
522+
523+
Args:
524+
placeholders: The placeholders that need to be searched and replaced in the given template value.
525+
value: The dictionary containing the actual values to replace with.
526+
template: The template string containing placeholders.
527+
528+
Returns:
529+
string: The resolved template value.
530+
"""
531+
for placeholder in placeholders:
532+
extracted_value = ''
533+
534+
if '#' in placeholder:
535+
# pick the 2nd chunk then remove the last character (i.e. `}`) of the string value
536+
node_pointer = placeholder.rsplit('#')[1].rstrip('}')
537+
try:
538+
extracted_value = resolve_pointer(value, node_pointer) if node_pointer else ''
539+
extracted_value = ApiHelper.json_serialize(extracted_value) \
540+
if type(extracted_value) in [list, dict] else str(extracted_value)
541+
except JsonPointerException:
542+
pass
543+
elif value is not None:
544+
extracted_value = ApiHelper.json_serialize(value)
545+
template = template.replace(placeholder, extracted_value)
546+
547+
return template
548+
549+
@staticmethod
550+
def resolve_template_placeholders(placeholders, values, template):
551+
"""Updates all placeholders in the given message template with provided value.
552+
553+
Args:
554+
placeholders: The placeholders that need to be searched and replaced in the given template value.
555+
values: The dictionary|string value which refers to the actual values to replace with.
556+
template: The template string containing placeholders.
557+
558+
Returns:
559+
string: The resolved template value.
560+
"""
561+
for placeholder in placeholders:
562+
if isinstance(values, abc.Mapping):
563+
# pick the last chunk then strip the last character (i.e. `}`) of the string value
564+
key = placeholder.rsplit('.', maxsplit=1)[-1].rstrip('}') if '.' in placeholder \
565+
else placeholder.lstrip('{').rstrip('}')
566+
value_to_replace = str(values.get(key)) if values.get(key) else ''
567+
template = template.replace(placeholder, value_to_replace)
568+
else:
569+
values = str(values) if values is not None else ''
570+
template = template.replace(placeholder, values)
571+
572+
return template
573+
519574
class CustomDate(object):
520575

521576
""" A base class for wrapper classes of datetime.

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ enum34~=1.1, >=1.1.10
44
apimatic-core-interfaces~=0.1.0
55
requests~=2.28.1
66
setuptools~=58.1.0
7+
jsonpointer~=2.3

setup.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
setup(
1414
name='apimatic-core',
15-
version='0.1.4',
15+
version='0.2.0',
1616
description='A library that contains core logic and utilities for '
1717
'consuming REST APIs using Python SDKs generated by APIMatic.',
1818
long_description=long_description,
@@ -27,7 +27,8 @@
2727
'python-dateutil~=2.8.1',
2828
'requests~=2.28.1',
2929
'enum34~=1.1, >=1.1.10',
30-
'setuptools~=58.1.0'
30+
'setuptools~=58.1.0',
31+
'jsonpointer~=2.3'
3132
],
3233
tests_require=[
3334
'pytest~=7.1.3',

tests/apimatic_core/base.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,24 @@ def read_file(file_name):
123123
@staticmethod
124124
def global_errors():
125125
return {
126-
'400': ErrorCase().description('400 Global').exception_type(GlobalTestException),
127-
'412': ErrorCase().description('Precondition Failed').exception_type(NestedModelException),
128-
'default': ErrorCase().description('Invalid response').exception_type(GlobalTestException),
126+
'400': ErrorCase().error_message('400 Global').exception_type(GlobalTestException),
127+
'412': ErrorCase().error_message('Precondition Failed').exception_type(NestedModelException),
128+
'3XX': ErrorCase().error_message('3XX Global').exception_type(GlobalTestException),
129+
'default': ErrorCase().error_message('Invalid response').exception_type(GlobalTestException),
130+
}
131+
132+
@staticmethod
133+
def global_errors_with_template_message():
134+
return {
135+
'400': ErrorCase()
136+
.error_message_template('error_code => {$statusCode}, header => {$response.header.accept}, '
137+
'body => {$response.body#/ServerCode} - {$response.body#/ServerMessage}')
138+
.exception_type(GlobalTestException),
139+
'412': ErrorCase()
140+
.error_message_template('global error message -> error_code => {$statusCode}, header => '
141+
'{$response.header.accept}, body => {$response.body#/ServerCode} - '
142+
'{$response.body#/ServerMessage} - {$response.body#/model/name}')
143+
.exception_type(NestedModelException)
129144
}
130145

131146
@staticmethod

0 commit comments

Comments
 (0)