37
37
])
38
38
39
39
40
- class _Validator (object ):
41
- """A collection of data validation utilities.
40
+ class _Unspecified (object ):
41
+ pass
42
42
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."""
46
49
47
50
@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
49
54
if not isinstance (uid , six .string_types ) or not uid or len (uid ) > 128 :
50
55
raise ValueError (
51
56
'Invalid uid: "{0}". The uid must be a non-empty string with no more than 128 '
52
57
'characters.' .format (uid ))
58
+ return uid
53
59
54
60
@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 ):
57
65
raise ValueError (
58
66
'Invalid email: "{0}". Email must be a non-empty string.' .format (email ))
59
67
parts = email .split ('@' )
60
68
if len (parts ) != 2 or not parts [0 ] or not parts [1 ]:
61
69
raise ValueError ('Malformed email address string: "{0}".' .format (email ))
70
+ return email
62
71
63
72
@classmethod
64
- def validate_phone (cls , phone ):
73
+ def validate_phone (cls , phone , required = False ):
65
74
"""Validates the specified phone number.
66
75
67
76
Phone number vlidation is very lax here. Backend will enforce E.164 spec compliance, and
68
77
normalize accordingly. Here we check if the number starts with + sign, and contains at
69
78
least one alphanumeric character.
70
79
"""
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 ):
72
83
raise ValueError ('Invalid phone number: "{0}". Phone number must be a non-empty '
73
84
'string.' .format (phone ))
74
85
if not phone .startswith ('+' ) or not re .search ('[a-zA-Z0-9]' , phone ):
75
86
raise ValueError ('Invalid phone number: "{0}". Phone number must be a valid, E.164 '
76
87
'compliant identifier.' .format (phone ))
88
+ return phone
77
89
78
90
@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
80
94
if not isinstance (password , six .string_types ) or len (password ) < 6 :
81
95
raise ValueError (
82
96
'Invalid password string. Password must be a string at least 6 characters long.' )
97
+ return password
83
98
84
99
@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
93
103
if not isinstance (display_name , six .string_types ) or not display_name :
94
104
raise ValueError (
95
105
'Invalid display name: "{0}". Display name must be a non-empty '
96
106
'string.' .format (display_name ))
107
+ return display_name
97
108
98
109
@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
100
113
if not isinstance (photo_url , six .string_types ) or not photo_url :
101
114
raise ValueError (
102
115
'Invalid photo URL: "{0}". Photo URL must be a non-empty '
@@ -105,63 +118,53 @@ def validate_photo_url(cls, photo_url):
105
118
parsed = urllib .parse .urlparse (photo_url )
106
119
if not parsed .netloc :
107
120
raise ValueError ('Malformed photo URL: "{0}".' .format (photo_url ))
121
+ return photo_url
108
122
except Exception :
109
123
raise ValueError ('Malformed photo URL: "{0}".' .format (photo_url ))
110
124
111
125
@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 ))
134
138
135
139
@classmethod
136
- def validate_custom_claims (cls , custom_claims ):
140
+ def validate_custom_claims (cls , custom_claims , required = False ):
137
141
"""Validates the specified custom claims.
138
142
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
140
144
characters, and the parsed JSON payload must not contain reserved JWT claims.
141
145
"""
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 :
148
150
raise ValueError (
149
151
'Custom claims payload must not exceed {0} '
150
152
'characters.' .format (MAX_CLAIMS_PAYLOAD_SIZE ))
151
153
try :
152
- parsed = json .loads (custom_claims )
154
+ parsed = json .loads (claims_str )
153
155
except Exception :
154
156
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
165
168
166
169
167
170
class ApiCallError (Exception ):
@@ -176,67 +179,20 @@ def __init__(self, code, message, error=None):
176
179
class UserManager (object ):
177
180
"""Provides methods for interacting with the Google Identity Toolkit."""
178
181
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
-
223
182
def __init__ (self , client ):
224
183
self ._client = client
225
184
226
185
def get_user (self , ** kwargs ):
227
186
"""Gets the user data corresponding to the provided key."""
228
187
if 'uid' in kwargs :
229
188
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 )]}
232
190
elif 'email' in kwargs :
233
191
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 )]}
236
193
elif 'phone_number' in kwargs :
237
194
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 )]}
240
196
else :
241
197
raise ValueError ('Unsupported keyword arguments: {0}.' .format (kwargs ))
242
198
@@ -272,10 +228,20 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS):
272
228
except requests .exceptions .RequestException as error :
273
229
self ._handle_http_error (USER_DOWNLOAD_ERROR , 'Failed to download user accounts.' , error )
274
230
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 ):
276
233
"""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 }
279
245
try :
280
246
response = self ._client .request ('post' , 'signupNewUser' , json = payload )
281
247
except requests .exceptions .RequestException as error :
@@ -285,30 +251,47 @@ def create_user(self, **kwargs):
285
251
raise ApiCallError (USER_CREATE_ERROR , 'Failed to create new user.' )
286
252
return response .get ('localId' )
287
253
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 ):
289
257
"""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
+ }
293
266
294
267
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 )
299
278
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 :
306
288
if custom_claims is None :
307
289
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 )
310
293
311
- self . _validate ( payload , self . _VALIDATORS , 'update user' )
294
+ payload = { k : v for k , v in payload . items () if v is not None }
312
295
try :
313
296
response = self ._client .request ('post' , 'setAccountInfo' , json = payload )
314
297
except requests .exceptions .RequestException as error :
@@ -321,7 +304,7 @@ def update_user(self, uid, **kwargs):
321
304
322
305
def delete_user (self , uid ):
323
306
"""Deletes the user identified by the specified user ID."""
324
- _Validator .validate_uid (uid )
307
+ _Validator .validate_uid (uid , required = True )
325
308
try :
326
309
response = self ._client .request ('post' , 'deleteAccount' , json = {'localId' : uid })
327
310
except requests .exceptions .RequestException as error :
@@ -338,24 +321,6 @@ def _handle_http_error(self, code, msg, error):
338
321
msg += '\n Reason: {0}' .format (error )
339
322
raise ApiCallError (code , msg , error )
340
323
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
-
359
324
360
325
class UserIterator (object ):
361
326
"""An iterator that allows iterating over user accounts, one at a time.
0 commit comments