8000 Low Level ETag API (#59) · fossabot/firebase-admin-python@5744b69 · GitHub
[go: up one dir, main page]

Skip to content

Commit 5744b69

Browse files
Alexander Whatleyhiranya911
authored andcommitted
Low Level ETag API (firebase#59)
* New functionality for handling transactions. * Changes to transaction function, and other minor fixes. * Refactored existing code, and added integration tests. * Added integration test and minor fixes. * A few minor changes. * Added lower level functionality for accessing ETag. * Refactored methods. * A few small fixes. * Preliminary changes to API. * Changes to get_if_changed function. * Removed etag method.
1 parent 1a937b0 commit 5744b69

File tree

3 files changed

+121
-44
lines changed

3 files changed

+121
-44
lines changed

firebase_admin/db.py

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -125,31 +125,58 @@ def child(self, path):
125125
full_path = self._pathurl + '/' + path
126126
return Reference(client=self._client, path=full_path)
127127

128-
def get(self):
129-
"""Returns the value at the current location of the database.
128+
def get(self, etag=False):
129+
"""Returns the value, and possibly the ETag, at the current location of the database.
130130
131131
Returns:
132-
object: Decoded JSON value of the current database Reference.
132+
object: Decoded JSON value of the current database Reference if etag=False, otherwise
133+
the decoded JSON value and the corresponding ETag.
133134
134135
Raises:
135136
ApiCallError: If an error occurs while communicating with the remote database server.
136137
"""
137-
return self._client.request('get', self._add_suffix())
138+
if etag:
139+
data, headers = self._client.request('get', self._add_suffix(),
140+
headers={'X-Firebase-ETag' : 'true'},
141+
resp_headers=True)
142+
etag = headers.get('ETag')
143+
return data, etag
144+
else:
145+
return self._client.request('get', self._add_suffix())
146+
147+
def get_if_changed(self, etag):
148+
"""Get data in this location if the ETag no longer matches.
149+
150+
Args:
151+
etag: The ETag value we want to check against the ETag in the current location.
152+
153+
Returns:
154+
object: Tuple of boolean of whether the request was successful, current location's etag,
155+
and snapshot of location's data if passed in etag does not match.
156+
157+
Raises:
158+
ValueError: If the ETag is not a string.
159+
"""
160+
#pylint: disable=protected-access
161+
if not isinstance(etag, six.string_types):
162+
raise ValueError('ETag must be a string.')
138163

139-
def _get_with_etag(self):
140-
"""Returns the value at the current location of the database, along with its ETag."""
141-
data, headers = self._client.request(
142-
'get', self._add_suffix(), headers={'X-Firebase-ETag' : 'true'}, resp_headers=True)
143-
etag = headers.get('ETag')
144-
return etag, data
164+
resp = self._client._do_request('get', self._add_suffix(),
165+
headers={'if-none-match': etag})
166+
if resp.status_code == 200:
167+
value, headers = resp.json(), resp.headers
168+
new_etag = headers.get('ETag')
169+
return True, new_etag, value
170+
elif resp.status_code == 304:
171+
return False, None, None
145172

146173
def set(self, value):
147174
"""Sets the data at this location to the given value.
148175
149176
The value must be JSON-serializable and not None.
150177
151178
Args:
152-
value: JSON-serialable value to be set at this location.
179+
value: JSON-serializable value to be set at this location.
153180
154181
Raises:
155182
ValueError: If the value is None.
@@ -160,6 +187,41 @@ def set(self, value):
160187
raise ValueError('Value must not be None.')
161188
self._client.request_oneway('put', self._add_suffix(), json=value, params='print=silent')
162189

190+
def set_if_unchanged(self, expected_etag, value):
191+
"""Sets the data at this location to the given value, if expected_etag is the same as the
192+
correct ETag value.
193+
194+
Args:
195+
expected_etag: Value of ETag we want to check.
196+
value: JSON-serializable value to be set at this location.
197+
198+
Returns:
199+
object: Tuple of boolean of whether the request was successful, current location's etag,
200+
and snapshot of location's data if passed in etag does not match.
201+
202+
Raises:
203+
ValueError: If the value is None, or if expected_etag is not a string.
204+
ApiCallError: If an error occurs while communicating with the remote database server.
205+
"""
206+
# pylint: disable=missing-raises-doc
207+
if not isinstance(expected_etag, six.string_types):
208+
raise ValueError('Expected ETag must be a string.')
209+
if value is None:
210+
raise ValueError('Value must not be none.')
211+
212+
try:
213+
self._client.request_oneway('put', self._add_suffix(),
214+
json=value, headers={'if-match': expected_etag})
215+
return True, expected_etag, value
216+
except ApiCallError as error:
217+
detail = error.detail
218+
if detail.response is not None and 'ETag' in detail.response.headers:
219+
etag = detail.response.headers['ETag']
220+
snapshot = detail.response.json()
221+
return False, etag, snapshot
222+
else:
223+
raise error
224+
163225
def push(self, value=''):
164226
"""Creates a new child node.
165227
@@ -197,25 +259,8 @@ def update(self, value):
197259
raise ValueError('Value argument must be a non-empty dictionary.')
198260
if None in value.keys() or None in value.values():
199261
raise ValueError('Dictionary must not contain None keys or values.')
200-
self._client.request_oneway('patch', self._add_suffix(), json=value, params='print=silent')
201-
202-
def _update_with_etag(self, value, etag):
203-
"""Sets the data at this location to the specified value, if the etag matches."""
204-
if not isinstance(etag, six.string_types):
205-
raise ValueError('ETag must be a string.')
206-
207-
try:
208-
self._client.request_oneway(
209-
'put', self._add_suffix(), json=value, headers={'if-match': etag})
210-
return True, etag, value
211-
except ApiCallError as error:
212-
detail = error.detail
213-
if detail.response is not None and 'ETag' in detail.response.headers:
214-
etag = detail.response.headers['ETag']
215-
snapshot = detail.response.json()
216-
return False, etag, snapshot
217-
else:
218-
raise error
262+
self._client.request_oneway('patch', self._add_suffix(), json=value,
263+
params='print=silent')
219264

220265
def delete(self):
221266
"""Deletes this node from the database.
@@ -253,16 +298,15 @@ def transaction(self, transaction_update):
253298
Raises:
254299
TransactionError: If the transaction aborts after exhausting all retry attempts.
255300
ValueError: If transaction_update is not a function.
256-
257301
"""
258302
if not callable(transaction_update):
259303
raise ValueError('transaction_update must be a function.')
260304

261305
tries = 0
262-
etag, data = self._get_with_etag()
306+
data, etag = self.get(etag=True)
263307
while tries < _TRANSACTION_MAX_RETRIES:
264308
new_data = transaction_update(data)
265-
success, etag, data = self._update_with_etag(new_data, etag)
309+
success, etag, data = self.set_if_unchanged(etag, new_data)
266310
if success:
267311
return new_data
268312
tries += 1
@@ -480,14 +524,14 @@ def __init__(self, message, error):
480524
Exception.__init__(self, message)
481525
self.detail = error
482526

483-
484527
class TransactionError(Exception):
485528
"""Represents an Exception encountered while performing a transaction."""
486529

487530
def __init__(self, message):
488531
Exception.__init__(self, message)
489532

490533

534+
491535
class _Sorter(object):
492536
"""Helper class for sorting query results."""
493537

@@ -674,8 +718,11 @@ def from_app(cls, app):
674718

675719
def request(self, method, urlpath, **kwargs):
676720
resp_headers = kwargs.pop('resp_headers', False)
721+
params = kwargs.get('params', None)
677722
resp = self._do_request(method, urlpath, **kwargs)
678-
if resp_headers:
723+
if resp_headers and params == 'print=silent':
724+
return resp.headers
725+
elif resp_headers:
679726
return resp.json(), resp.headers
680727
else:
681728
return resp.json()

integration/test_db.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,23 +150,34 @@ def test_update_nested_children(self, testref):
150150
assert edward.get() == {'name' : 'Edward Cope', 'since' : 1840}
151151
assert jack.get() == {'name' : 'Jack Horner', 'since' : 1946}
152152

153-
def test_get_and_update_with_etag(self, testref):
153+
def test_get_if_changed(self, testref):
154154
python = testref.parent
155155
push_data = {'name' : 'Edward Cope', 'since' : 1800}
156156
edward = python.child('users').push(push_data)
157-
etag, data = edward._get_with_etag()
157+
changed_data = edward.get_if_changed('wrong_etag')
158+
assert changed_data[0]
159+
assert changed_data[2] == push_data
160+
161+
unchanged_data = edward.get_if_changed(changed_data[1])
162+
assert unchanged_data == (False, None, None)
163+
164+
def test_get_and_set_with_etag(self, testref):
165+
python = testref.parent
166+
push_data = {'name' : 'Edward Cope', 'since' : 1800}
167+
edward = python.child('users').push(push_data)
168+
data, etag = edward.get(etag=True)
158169
assert data == push_data
159170
assert isinstance(etag, six.string_types)
160171

161172
update_data = {'name' : 'Jack Horner', 'since' : 1940}
162-
failed_update = edward._update_with_etag(update_data, 'invalid-etag')
173+
failed_update = edward.set_if_unchanged('invalid-etag', update_data)
163174
assert failed_update == (False, etag, push_data)
164175

165-
successful_update = edward._update_with_etag(update_data, etag)
176+
successful_update = edward.set_if_unchanged(etag, update_data)
166177
assert successful_update[0]
167178
assert successful_update[2] == update_data
168179

169-
def test_transation(self, testref):
180+
def test_transaction(self, testref):
170181
python = testref.parent
171182
def transaction_update(snapshot):
172183
snapshot['name'] += ' Owen'

tests/test_db.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ def __init__(self, data, status, recorder):
3434

3535
def send(self, request, **kwargs):
3636
if_match = request.headers.get('if-match')
37+
if_none_match = request.headers.get('if-none-match')
3738
if if_match and if_match != MockAdapter._ETAG:
3839
response = Response()
3940
response._content = request.body
4041
response.headers = {'ETag': MockAdapter._ETAG}
4142
raise exceptions.RequestException(response=response)
43+
4244
resp = super(MockAdapter, self).send(request, **kwargs)
4345
resp.headers = {'ETag': MockAdapter._ETAG}
46+
if if_none_match and if_none_match == MockAdapter._ETAG:
47+
resp.status_code = 304
4448
return resp
4549

4650

@@ -146,13 +150,28 @@ def test_get_value(self, data):
146150
def test_get_with_etag(self, data):
147151
ref = db.reference('/test')
148152
recorder = self.instrument(ref, json.dumps(data))
149-
assert ref._get_with_etag() == ('0', data)
153+
assert ref.get(etag=True) == (data, '0')
150154
assert len(recorder) == 1
151155
assert recorder[0].method == 'GET'
152156
assert recorder[0].url == 'https://test.firebaseio.com/test.json'
153157
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
154158
assert recorder[0].headers['User-Agent'] == db._USER_AGENT
155159

160+
@pytest.mark.parametrize('data', valid_values)
161+
def test_get_if_changed(self, data):
162+
ref = db.reference('/test')
163+
recorder = self.instrument(ref, json.dumps(data))
164+
165+
assert ref.get_if_changed('1') == (True, '0', data)
166+
assert len(recorder) == 1
167+
assert recorder[0].method == 'GET'
168+
assert recorder[0].url == 'https://test.firebaseio.com/test.json'
169+
170+
assert ref.get_if_changed('0') == (False, None, None)
171+
assert len(recorder) == 2
172+
assert recorder[0].method == 'GET'
173+
assert recorder[0].url == 'https://test.firebaseio.com/test.json'
174+
156175
@pytest.mark.parametrize('data', valid_values)
157176
def test_order_by_query(self, data):
158177
ref = db.reference('/test')
@@ -229,19 +248,19 @@ def test_update_children(self):
229248
assert json.loads(recorder[0].body.decode()) == data
230249
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
231250

232-
def test_update_with_etag(self):
251+
def test_set_with_etag(self):
233252
ref = db.reference('/test')
234253
data = {'foo': 'bar'}
235254
recorder = self.instrument(ref, json.dumps(data))
236-
vals = ref._update_with_etag(data, '0')
255+
vals = ref.set_if_unchanged('0', data)
237256
assert vals == (True, '0', data)
238257
assert len(recorder) == 1
239258
assert recorder[0].method == 'PUT'
240259
assert recorder[0].url == 'https://test.firebaseio.com/test.json'
241260
assert json.loads(recorder[0].body.decode()) == data
242261
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
243262

244-
vals = ref._update_with_etag(data, '1')
263+
vals = ref.set_if_unchanged('1', data)
245264
assert vals == (False, '0', data)
246265
assert len(recorder) == 1
247266

0 commit comments

Comments
 (0)
0