Skip to content

Commit

Permalink
feat: parse sgid userinfo data (#42)
Browse files Browse the repository at this point in the history
This commit adds a new method, parseData, which helps to parse the
sgid userinfo object so that any stringified array or object values
are parsed as objects.

This commit is implemented according to the SDK implementation
requirements as linked here: https://www.notion.so/opengov/SDK-implementation-requirements-1f9b7cbd2bd4406b85d6645fe3e365dd#dcabe67d14034525bfd5e84acc1c59c6

This commit also adds the necessary unit tests for the newly introduced
utility functions that support parseDatafeat: parse sgid userinfo data
  • Loading branch information
kwajiehao authored Oct 11, 2023
1 parent 1ddc921 commit 2ebcbae
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 8 deletions.
19 changes: 12 additions & 7 deletions sgid_client/SgidClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
from .error import Errors, get_network_error_message, get_www_authenticate_error_message
from .util import (
convert_to_pkcs8,
is_stringified_array_or_object,
safe_json_parse
is_sgid_userinfo_object,
parse_individual_data_value
)

API_VERSION = 2
Expand Down Expand Up @@ -224,16 +224,21 @@ def userinfo(self, sub: str, access_token: str) -> UserInfoReturn:
)
return UserInfoReturn(sub=res_body["sub"], data=decrypted_data)

def parseData(self, dataValue: str) -> dict | list | str:
def parseData(self, data: object) -> dict[str, dict | list | str]:
"""Parses sgID user data
Args:
dataValue (str): A value from the `data` object returned from the `userinfo` method.
data (str): a `data` object returned from the `userinfo` method.
Returns:
The parsed data value. If the input is a string, then a string is returned. If a
stringified array or object is passed in, then an array or object is returned respectively.
"""
if (is_stringified_array_or_object(dataValue)):
return safe_json_parse(dataValue)
return dataValue
if not is_sgid_userinfo_object(data):
raise Exception(Errors.INVALID_SGID_USERINFO_DATA_ERROR)

result = {}
for key, value in data.items():
result[key] = parse_individual_data_value(value)

return result
2 changes: 2 additions & 0 deletions sgid_client/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class SgidClientError(NamedTuple):
USERINFO_DATA_DECRYPT_FAILED: str
CODE_VERIFIER_LENGTH_ERROR: str
PKCE_PAIR_LENGTH_ERROR: str
INVALID_SGID_USERINFO_DATA_ERROR: str


Errors = SgidClientError(
Expand Down Expand Up @@ -56,6 +57,7 @@ class SgidClientError(NamedTuple):
USERINFO_DATA_DECRYPT_FAILED="Decryption of data failed. Check that you passed the correct private key to the SgidClient constructor.",
CODE_VERIFIER_LENGTH_ERROR="Code verifier should have a minimum length of 43 and a maximum length of 128",
PKCE_PAIR_LENGTH_ERROR="generate_pkce_pair should receive a minimum length of 43 and a maximum length of 128",
INVALID_SGID_USERINFO_DATA_ERROR="Failed to parse sgID userinfo data object. Check that the input is a valid object."
)


Expand Down
40 changes: 39 additions & 1 deletion sgid_client/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def convert_to_pkcs8(private_key: str) -> str:
except Exception as exc:
raise Exception(Errors.PRIVATE_KEY_IMPORT) from exc


def is_stringified_array_or_object(possible_array_or_object_string: str) -> bool:
"""Checks whether a string starts and ends with square brackets or starts and ends with curly brackets.
Expand All @@ -33,6 +34,7 @@ def is_stringified_array_or_object(possible_array_or_object_string: str) -> bool
"""
return (possible_array_or_object_string[0] == '[' and possible_array_or_object_string[len(possible_array_or_object_string)-1] == ']') or (possible_array_or_object_string[0] == '[' and possible_array_or_object_string[len(possible_array_or_object_string)-1] == ']')


def safe_json_parse(json_string: str) -> dict | list | str:
"""Safely parses a stringified JSON object or array.
Expand All @@ -45,4 +47,40 @@ def safe_json_parse(json_string: str) -> dict | list | str:
try:
return json.loads(json_string)
except Exception:
return json_string
return json_string


def is_sgid_userinfo_object(data: object) -> bool:
"""Checks whether the input is a valid sgID userinfo object by checking:
1. Whether the input is a defined dictionary
2. That all keys and values in the dictionary are strings
Args:
dataValue (object): an unknown object
Returns:
A boolean that indicates whether the input was a valid sgID userinfo object
"""
if type(data) is not dict:
return False

for key, value in data.items():
if type(key) is not str or type(value) is not str:
return False

return True


def parse_individual_data_value(dataValue: str) -> dict | list | str:
"""Parses individual sgID user data values
Args:
dataValue (str): A value from the `data` object returned from the `userinfo` method.
Returns:
The parsed data value. If the input is a string, then a string is returned. If a
stringified array or object is passed in, then an array or object is returned respectively.
"""
if (is_stringified_array_or_object(dataValue)):
return safe_json_parse(dataValue)
return dataValue
98 changes: 98 additions & 0 deletions tests/SgidClient_parsedata_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import pytest
import json
from urllib.parse import urlparse, parse_qs
from .mocks.constants import MOCK_CONSTANTS
from .mocks.helpers import (
get_client,
)
from sgid_client.error import Errors


class TestParseData:
def test_parse_valid_object(self):
# Arrange
client = get_client()

# Act
stringifiedChildRecords = '[{"nric":"T1872646C","name":"LIM YONG JIN","date_of_birth":"2018-05-05","sex":"MALE","race":"MALAY","life_status":"ALIVE","nationality":"BRITISH OVERSEAS TERRITORIES CITIZEN","residential_status":"PR"}]'
inputData = {
'myinfo.name': 'Kwa Jie Hao',
'myinfo.sponsored_child_records': stringifiedChildRecords,
}
parsedData = client.parseData(inputData)

# Assert
expectedData = {
'myinfo.name': 'Kwa Jie Hao',
'myinfo.sponsored_child_records': json.loads(stringifiedChildRecords),
}
assert (parsedData == expectedData)

# It should do nothing if there are no stringified arrays or objects in the input data object
def test_parse_valid_object_do_nothing(self):
# Arrange
client = get_client()

# Act
testDict = {'a': 'test'}
parsedData = client.parseData(testDict)

# Assert
assert (parsedData == testDict)

def test_parse_none_value(self):
# Arrange
client = get_client()

# Act
with pytest.raises(
Exception,
match=Errors.INVALID_SGID_USERINFO_DATA_ERROR,
):
client.parseData(None)

def test_parse_array_value(self):
# Arrange
client = get_client()

# Act
with pytest.raises(
Exception,
match=Errors.INVALID_SGID_USERINFO_DATA_ERROR,
):
client.parseData(['test'])

def test_parse_string_value(self):
# Arrange
client = get_client()

# Act
with pytest.raises(
Exception,
match=Errors.INVALID_SGID_USERINFO_DATA_ERROR,
):
client.parseData('test')

# It should throw an error if the input is an object, but has non-string values
def test_parse_object_with_non_string_values(self):
# Arrange
client = get_client()

# Act
with pytest.raises(
Exception,
match=Errors.INVALID_SGID_USERINFO_DATA_ERROR,
):
client.parseData({'test': 123})

# It should throw an error if the input is an object, but has non-string values
def test_parse_object_with_non_string_keys(self):
# Arrange
client = get_client()

# Act
with pytest.raises(
Exception,
match=Errors.INVALID_SGID_USERINFO_DATA_ERROR,
):
client.parseData({123: 'test'})

0 comments on commit 2ebcbae

Please sign in to comment.