From 025d8dde097db8b5b8e1c5d38173398fd9145567 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Jun 2018 13:22:52 +0200 Subject: [PATCH] Use Idempotency-Key header when posting toots --- CHANGELOG.md | 2 ++ tests/test_console.py | 16 ++++++++++++---- toot/api.py | 8 +++++++- toot/http.py | 5 +++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a49d5..3ef7488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Changelog **0.19.0 (TBA)** * Add support for replying to a toot (#6) +* Use Idempotency-Key header to prevent multiple toots being posted if request + is retried **0.18.0 (2018-06-12)** diff --git a/tests/test_console.py b/tests/test_console.py index 9a917ba..7cf1ee8 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -2,7 +2,9 @@ import io import pytest import re +import uuid +from collections import namedtuple from unittest import mock from toot import console, User, App, http @@ -13,6 +15,8 @@ from tests.utils import MockResponse app = App('habunek.com', 'https://habunek.com', 'foo', 'bar') user = User('habunek.com', 'ivan@habunek.com', 'xxx') +MockUuid = namedtuple("MockUuid", ["hex"]) + def uncolorize(text): """Remove ANSI color sequences from a string""" @@ -25,8 +29,10 @@ def test_print_usage(capsys): assert "toot - a Mastodon CLI client" in out +@mock.patch('uuid.uuid4') @mock.patch('toot.http.post') -def test_post_defaults(mock_post, capsys): +def test_post_defaults(mock_post, mock_uuid, capsys): + mock_uuid.return_value = MockUuid("rock-on") mock_post.return_value = MockResponse({ 'url': 'https://habunek.com/@ihabunek/1234567890' }) @@ -40,7 +46,7 @@ def test_post_defaults(mock_post, capsys): 'sensitive': False, 'spoiler_text': None, 'in_reply_to_id': None, - }) + }, headers={"Idempotency-Key": "rock-on"}) out, err = capsys.readouterr() assert 'Toot posted' in out @@ -48,8 +54,10 @@ def test_post_defaults(mock_post, capsys): assert not err +@mock.patch('uuid.uuid4') @mock.patch('toot.http.post') -def test_post_with_options(mock_post, capsys): +def test_post_with_options(mock_post, mock_uuid, capsys): + mock_uuid.return_value = MockUuid("up-the-irons") args = [ 'Hello world', '--visibility', 'unlisted', @@ -71,7 +79,7 @@ def test_post_with_options(mock_post, capsys): 'sensitive': True, 'spoiler_text': "Spoiler!", 'in_reply_to_id': 123, - }) + }, headers={"Idempotency-Key": "up-the-irons"}) out, err = capsys.readouterr() assert 'Toot posted' in out diff --git a/toot/api.py b/toot/api.py index c5fe79e..ce7270b 100644 --- a/toot/api.py +++ b/toot/api.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import re +import uuid from urllib.parse import urlparse, urlencode, quote @@ -90,6 +91,11 @@ def post_status( Posts a new status. https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#posting-a-new-status """ + + # Idempotency key assures the same status is not posted multiple times + # if the request is retried. + headers = {"Idempotency-Key": uuid.uuid4().hex} + return http.post(app, user, '/api/v1/statuses', { 'status': status, 'media_ids[]': media_ids, @@ -97,7 +103,7 @@ def post_status( 'sensitive': sensitive, 'spoiler_text': spoiler_text, 'in_reply_to_id': in_reply_to_id, - }).json() + }, headers=headers).json() def timeline_home(app, user): diff --git a/toot/http.py b/toot/http.py index 87ad374..b63ee8f 100644 --- a/toot/http.py +++ b/toot/http.py @@ -58,9 +58,10 @@ def anon_get(url, params=None): return process_response(response) -def post(app, user, url, data=None, files=None, allow_redirects=True): +def post(app, user, url, data=None, files=None, allow_redirects=True, headers={}): url = app.base_url + url - headers = {"Authorization": "Bearer " + user.access_token} + + headers["Authorization"] = "Bearer " + user.access_token request = Request('POST', url, headers, files, data) response = send_request(request, allow_redirects)