Skip to content

Commit 18657b9

Browse files
authored
Refactored the _user_mgt module (#154)
* Refactored the validators and paylaod constructors * Fixed a lint error and other regressions * Facilitating more duck typing in the API
1 parent b01f30d commit 18657b9

File tree

2 files changed

+139
-188
lines changed

2 files changed

+139
-188
lines changed

firebase_admin/_user_mgt.py

Lines changed: 115 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -37,66 +37,79 @@
3737
])
3838

3939

40-
class _Validator(object):
41-
"""A collection of data validation utilities.
40+
class _Unspecified(object):
41+
pass
4242

43-
Methods provided in this class raise ValueErrors if any validations fail. Normal returns
44-
signal success.
45-
"""
43+
# Use this internally, until sentinels are available in the public API.
44+
_UNSPECIFIED = _Unspecified()
45+
46+
47+
class _Validator(object):
48+
"""A collection of data validation utilities."""
4649

4750
@classmethod
48-
def validate_uid(cls, uid):
51+
def validate_uid(cls, uid, required=False):
52+
if uid is None and not required:
53+
return None
4954
if not isinstance(uid, six.string_types) or not uid or len(uid) > 128:
5055
raise ValueError(
5156
'Invalid uid: "{0}". The uid must be a non-empty string with no more than 128 '
5257
'characters.'.format(uid))
58+
return uid
5359

5460
@classmethod
55-
def validate_email(cls, email):
56-
if not isinstance(email, six.string_types) or not email:
61+
def validate_email(cls, email, required=False):
62+
if email is None and not required:
63+
return None
64+
if not isinstance(email, six.string_types):
5765
raise ValueError(
5866
'Invalid email: "{0}". Email must be a non-empty string.'.format(email))
5967
parts = email.split('@')
6068
if len(parts) != 2 or not parts[0] or not parts[1]:
6169
raise ValueError('Malformed email address string: "{0}".'.format(email))
70+
return email
6271

6372
@classmethod
64-
def validate_phone(cls, phone):
73+
def validate_phone(cls, phone, required=False):
6574
"""Validates the specified phone number.
6675
6776
Phone number vlidation is very lax here. Backend will enforce E.164 spec compliance, and
6877
normalize accordingly. Here we check if the number starts with + sign, and contains at
6978
least one alphanumeric character.
7079
"""
71-
if not isinstance(phone, six.string_types) or not phone:
80+
if phone is None and not required:
81+
return None
82+
if not isinstance(phone, six.string_types):
7283
raise ValueError('Invalid phone number: "{0}". Phone number must be a non-empty '
7384
'string.'.format(phone))
7485
if not phone.startswith('+') or not re.search('[a-zA-Z0-9]', phone):
7586
raise ValueError('Invalid phone number: "{0}". Phone number must be a valid, E.164 '
7687
'compliant identifier.'.format(phone))
88+
return phone
7789

7890
@classmethod
79-
def validate_password(cls, password):
91+
def validate_password(cls, password, required=False):
92+
if password is None and not required:
93+
return None
8094
if not isinstance(password, six.string_types) or len(password) < 6:
8195
raise ValueError(
8296
'Invalid password string. Password must be a string at least 6 characters long.')
97+
return password
8398

8499
@classmethod
85-
def validate_email_verified(cls, email_verified):
86-
if not isinstance(email_verified, bool):
87-
raise ValueError(
88-
'Invalid email verified status: "{0}". Email verified status must be '
89-
'boolean.'.format(email_verified))
90-
91-
@classmethod
92-
def validate_display_name(cls, display_name):
100+
def validate_display_name(cls, display_name, required=False):
101+
if display_name is None and not required:
102+
return None
93103
if not isinstance(display_name, six.string_types) or not display_name:
94104
raise ValueError(
95105
'Invalid display name: "{0}". Display name must be a non-empty '
96106
'string.'.format(display_name))
107+
return display_name
97108

98109
@classmethod
99-
def validate_photo_url(cls, photo_url):
110+
def validate_photo_url(cls, photo_url, required=False):
111+
if photo_url is None and not required:
112+
return None
100113
if not isinstance(photo_url, six.string_types) or not photo_url:
101114
raise ValueError(
102115
'Invalid photo URL: "{0}". Photo URL must be a non-empty '
@@ -105,63 +118,53 @@ def validate_photo_url(cls, photo_url):
105118
parsed = urllib.parse.urlparse(photo_url)
106119
if not parsed.netloc:
107120
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
121+
return photo_url
108122
except Exception:
109123
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
110124

111125
@classmethod
112-
def validate_valid_since(cls, valid_since):
113-
# isinstance(True, int) is True hence the extra check
114-
if valid_since is None or isinstance(valid_since, bool) or not isinstance(valid_since, int):
115-
raise ValueError(
116-
'Invalid time string for: "{0}". Valid Since must be an int'.format(valid_since))
117-
if int(valid_since) <= 0:
118-
raise ValueError(
119-
'Invalid valid_since: must be a positive interger. {0}'.format(valid_since))
120-
121-
@classmethod
122-
def validate_disabled(cls, disabled):
123-
if not isinstance(disabled, bool):
124-
raise ValueError(
125-
'Invalid disabled status: "{0}". Disabled status must be '
126-
'boolean.'.format(disabled))
127-
128-
@classmethod
129-
def validate_delete_list(cls, delete_attr):
130-
if not isinstance(delete_attr, list) or not delete_attr:
131-
raise ValueError(
132-
'Invalid delete list: "{0}". Delete list must be a '
133-
'non-empty list.'.format(delete_attr))
126+
def validate_timestamp(cls, timestamp, label, required=False):
127+
if timestamp is None and not required:
128+
return None
129+
if isinstance(timestamp, bool):
130+
raise ValueError('Boolean value specified as timestamp.')
131+
try:
132+
timestamp_int = int(timestamp)
133+
if timestamp_int <= 0:
134+
raise ValueError('{0} timestamp must be a positive interger.'.format(label))
135+
return timestamp_int
136+
except TypeError:
137+
raise ValueError('Invalid type for timestamp value: {0}.'.format(timestamp))
134138

135139
@classmethod
136-
def validate_custom_claims(cls, custom_claims):
140+
def validate_custom_claims(cls, custom_claims, required=False):
137141
"""Validates the specified custom claims.
138142
139-
Custom claims must be specified as a JSON string.The string must not exceed 1000
143+
Custom claims must be specified as a JSON string. The string must not exceed 1000
140144
characters, and the parsed JSON payload must not contain reserved JWT claims.
141145
"""
142-
if not isinstance(custom_claims, six.string_types) or not custom_claims:
143-
raise ValueError(
144-
'Invalid custom claims: "{0}". Custom claims must be a non-empty JSON '
145-
'string.'.format(custom_claims))
146-
147-
if len(custom_claims) > MAX_CLAIMS_PAYLOAD_SIZE:
146+
if custom_claims is None and not required:
147+
return None
148+
claims_str = str(custom_claims)
149+
if len(claims_str) > MAX_CLAIMS_PAYLOAD_SIZE:
148150
raise ValueError(
149151
'Custom claims payload must not exceed {0} '
150152
'characters.'.format(MAX_CLAIMS_PAYLOAD_SIZE))
151153
try:
152-
parsed = json.loads(custom_claims)
154+
parsed = json.loads(claims_str)
153155
except Exception:
154156
raise ValueError('Failed to parse custom claims string as JSON.')
155-
else:
156-
if not isinstance(parsed, dict):
157-
raise ValueError('Custom claims must be parseable as a JSON object.')
158-
invalid_claims = RESERVED_CLAIMS.intersection(set(parsed.keys()))
159-
if len(invalid_claims) > 1:
160-
joined = ', '.join(sorted(invalid_claims))
161-
raise ValueError('Claims "{0}" are reserved, and must not be set.'.format(joined))
162-
elif len(invalid_claims) == 1:
163-
raise ValueError(
164-
'Claim "{0}" is reserved, and must not be set.'.format(invalid_claims.pop()))
157+
158+
if not isinstance(parsed, dict):
159+
raise ValueError('Custom claims must be parseable as a JSON object.')
160+
invalid_claims = RESERVED_CLAIMS.intersection(set(parsed.keys()))
161+
if len(invalid_claims) > 1:
162+
joined = ', '.join(sorted(invalid_claims))
163+
raise ValueError('Claims "{0}" are reserved, and must not be set.'.format(joined))
164+
elif len(invalid_claims) == 1:
165+
raise ValueError(
166+
'Claim "{0}" is reserved, and must not be set.'.format(invalid_claims.pop()))
167+
return claims_str
165168

166169

167170
class ApiCallError(Exception):
@@ -176,67 +179,20 @@ def __init__(self, code, message, error=None):
176179
class UserManager(object):
177180
"""Provides methods for interacting with the Google Identity Toolkit."""
178181

179-
_VALIDATORS = {
180-
'customAttributes' : _Validator.validate_custom_claims,
181-
'deleteAttribute' : _Validator.validate_delete_list,
182-
'deleteProvider' : _Validator.validate_delete_list,
183-
'disabled' : _Validator.validate_disabled,
184-
'disableUser' : _Validator.validate_disabled,
185-
'displayName' : _Validator.validate_display_name,
186-
'email' : _Validator.validate_email,
187-
'emailVerified' : _Validator.validate_email_verified,
188-
'localId' : _Validator.validate_uid,
189-
'password' : _Validator.validate_password,
190-
'phoneNumber' : _Validator.validate_phone,
191-
'photoUrl' : _Validator.validate_photo_url,
192-
'validSince' : _Validator.validate_valid_since,
193-
}
194-
195-
_CREATE_USER_FIELDS = {
196-
'uid' : 'localId',
197-
'display_name' : 'displayName',
198-
'email' : 'email',
199-
'email_verified' : 'emailVerified',
200-
'phone_number' : 'phoneNumber',
201-
'photo_url' : 'photoUrl',
202-
'password' : 'password',
203-
'disabled' : 'disabled',
204-
}
205-
206-
_UPDATE_USER_FIELDS = {
207-
'display_name' : 'displayName',
208-
'email' : 'email',
209-
'email_verified' : 'emailVerified',
210-
'phone_number' : 'phoneNumber',
211-
'photo_url' : 'photoUrl',
212-
'password' : 'password',
213-
'disabled' : 'disableUser',
214-
'custom_claims' : 'customAttributes',
215-
'valid_since' : 'validSince',
216-
}
217-
218-
_REMOVABLE_FIELDS = {
219-
'displayName' : 'DISPLAY_NAME',
220-
'photoUrl' : 'PHOTO_URL'
221-
}
222-
223182
def __init__(self, client):
224183
self._client = client
225184

226185
def get_user(self, **kwargs):
227186
"""Gets the user data corresponding to the provided key."""
228187
if 'uid' in kwargs:
229188
key, key_type = kwargs.pop('uid'), 'user ID'
230-
_Validator.validate_uid(key)
231-
payload = {'localId' : [key]}
189+
payload = {'localId' : [_Validator.validate_uid(key, required=True)]}
232190
elif 'email' in kwargs:
233191
key, key_type = kwargs.pop('email'), 'email'
234-
_Validator.validate_email(key)
235-
payload = {'email' : [key]}
192+
payload = {'email' : [_Validator.validate_email(key, required=True)]}
236193
elif 'phone_number' in kwargs:
237194
key, key_type = kwargs.pop('phone_number'), 'phone number'
238-
_Validator.validate_phone(key)
239-
payload = {'phoneNumber' : [key]}
195+
payload = {'phoneNumber' : [_Validator.validate_phone(key, required=True)]}
240196
else:
241197
raise ValueError('Unsupported keyword arguments: {0}.'.format(kwargs))
242198

@@ -272,10 +228,20 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS):
272228
except requests.exceptions.RequestException as error:
273229
self._handle_http_error(USER_DOWNLOAD_ERROR, 'Failed to download user accounts.', error)
274230

275-
def create_user(self, **kwargs):
231+
def create_user(self, uid=None, display_name=None, email=None, phone_number=None,
232+
photo_url=None, password=None, disabled=None, email_verified=None):
276233
"""Creates a new user account with the specified properties."""
277-
payload = self._init_payload('create_user', UserManager._CREATE_USER_FIELDS, **kwargs)
278-
self._validate(payload, self._VALIDATORS, 'create user')
234+
payload = {
235+
'localId': _Validator.validate_uid(uid),
236+
'displayName': _Validator.validate_display_name(display_name),
237+
'email': _Validator.validate_email(email),
238+
'phoneNumber': _Validator.validate_phone(phone_number),
239+
'photoUrl': _Validator.validate_photo_url(photo_url),
240+
'password': _Validator.validate_password(password),
241+
'emailVerified': bool(email_verified) if email_verified is not None else None,
242+
'disabled': bool(disabled) if disabled is not None else None,
243+
}
244+
payload = {k: v for k, v in payload.items() if v is not None}
279245
try:
280246
response = self._client.request('post', 'signupNewUser', json=payload)
281247
except requests.exceptions.RequestException as error:
@@ -285,30 +251,47 @@ def create_user(self, **kwargs):
285251
raise ApiCallError(USER_CREATE_ERROR, 'Failed to create new user.')
286252
return response.get('localId')
287253

288-
def update_user(self, uid, **kwargs):
254+
def update_user(self, uid, display_name=_UNSPECIFIED, email=None, phone_number=_UNSPECIFIED,
255+
photo_url=_UNSPECIFIED, password=None, disabled=None, email_verified=None,
256+
valid_since=None, custom_claims=_UNSPECIFIED):
289257
"""Updates an existing user account with the specified properties"""
290-
_Validator.validate_uid(uid)
291-
payload = self._init_payload('update_user', UserManager._UPDATE_USER_FIELDS, **kwargs)
292-
payload['localId'] = uid
258+
payload = {
259+
'localId': _Validator.validate_uid(uid, required=True),
260+
'email': _Validator.validate_email(email),
261+
'password': _Validator.validate_password(password),
262+
'validSince': _Validator.validate_timestamp(valid_since, 'valid_since'),
263+
'emailVerified': bool(email_verified) if email_verified is not None else None,
264+
'disableUser': bool(disabled) if disabled is not None else None,
265+
}
293266

294267
remove = []
295-
for key, value in UserManager._REMOVABLE_FIELDS.items():
296-
if key in payload and payload[key] is None:
297-
remove.append(value)
298-
del payload[key]
268+
if display_name is not _UNSPECIFIED:
269+
if display_name is None:
270+
remove.append('DISPLAY_NAME')
271+
else:
272+
payload['displayName'] = _Validator.validate_display_name(display_name)
273+
if photo_url is not _UNSPECIFIED:
274+
if photo_url is None:
275+
remove.append('PHOTO_URL')
276+
else:
277+
payload['photoUrl'] = _Validator.validate_photo_url(photo_url)
299278
if remove:
300-
payload['deleteAttribute'] = sorted(remove)
301-
if 'phoneNumber' in payload and payload['phoneNumber'] is None:
302-
payload['deleteProvider'] = ['phone']
303-
del payload['phoneNumber']
304-
if 'customAttributes' in payload:
305-
custom_claims = payload['customAttributes']
279+
payload['deleteAttribute'] = remove
280+
281+
if phone_number is not _UNSPECIFIED:
282+
if phone_number is None:
283+
payload['deleteProvider'] = ['phone']
284+
else:
285+
payload['phoneNumber'] = _Validator.validate_phone(phone_number)
286+
287+
if custom_claims is not _UNSPECIFIED:
306288
if custom_claims is None:
307289
custom_claims = {}
308-
if isinstance(custom_claims, dict):
309-
payload['customAttributes'] = json.dumps(custom_claims)
290+
json_claims = json.dumps(custom_claims) if isinstance(
291+
custom_claims, dict) else custom_claims
292+
payload['customAttributes'] = _Validator.validate_custom_claims(json_claims)
310293

311-
self._validate(payload, self._VALIDATORS, 'update user')
294+
payload = {k: v for k, v in payload.items() if v is not None}
312295
try:
313296
response = self._client.request('post', 'setAccountInfo', json=payload)
314297
except requests.exceptions.RequestException as error:
@@ -321,7 +304,7 @@ def update_user(self, uid, **kwargs):
321304

322305
def delete_user(self, uid):
323306
"""Deletes the user identified by the specified user ID."""
324-
_Validator.validate_uid(uid)
307+
_Validator.validate_uid(uid, required=True)
325308
try:
326309
response = self._client.request('post', 'deleteAccount', json={'localId' : uid})
327310
except requests.exceptions.RequestException as error:
@@ -338,24 +321,6 @@ def _handle_http_error(self, code, msg, error):
338321
msg += '\nReason: {0}'.format(error)
339322
raise ApiCallError(code, msg, error)
340323

341-
def _init_payload(self, operation, fields, **kwargs):
342-
payload = {}
343-
for key, value in fields.items():
344-
if key in kwargs:
345-
payload[value] = kwargs.pop(key)
346-
if kwargs:
347-
unexpected_keys = ', '.join(kwargs.keys())
348-
raise ValueError(
349-
'Unsupported arguments: "{0}" in call to {1}()'.format(unexpected_keys, operation))
350-
return payload
351-
352-
def _validate(self, properties, validators, operation):
353-
for key, value in properties.items():
354-
validator = validators.get(key)
355-
if not validator:
356-
raise ValueError('Unsupported property: "{0}" in {1} call.'.format(key, operation))
357-
validator(value)
358-
359324

360325
class UserIterator(object):
361326
"""An iterator that allows iterating over user accounts, one at a time.

0 commit comments

Comments
 (0)