Skip to content

Commit d3e613a

Browse files
authored
Merge pull request #7703 from freedomofpress/7639-apiv2-minor-versioning
feat(`api2`): `Prefer: securedrop=x` limits response shape to minor version `2.x`
2 parents 6344389 + e888fef commit d3e613a

File tree

4 files changed

+152
-34
lines changed

4 files changed

+152
-34
lines changed

securedrop/journalist_app/api2/__init__.py

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@
2424
PREFIX_MAX_LEN = inspect(Source).columns["uuid"].type.length
2525

2626

27+
# Magic numbers to avoid having to define an `IntEnum` somewhere that can be
28+
# imported from `securedrop.models`:
29+
#
30+
# 0. Initial implementation
31+
# 1. `Index` and `BatchResponse` include `journalists`
32+
# 2. `Reply` and `Submission` objects include `interaction_count`
33+
# 3. `BatchRequest` accepts `events` to process, with results returned in
34+
# `BatchResponse.events`
35+
API_MINOR_VERSION = 3 # 2.x
36+
37+
38+
def get_request_minor_version() -> int:
39+
try:
40+
prefer = request.headers.get("Prefer", f"securedrop={API_MINOR_VERSION}")
41+
minor_version = int(prefer.split("=")[1])
42+
if 0 <= minor_version <= API_MINOR_VERSION:
43+
return minor_version
44+
else:
45+
return API_MINOR_VERSION
46+
except (IndexError, ValueError):
47+
return API_MINOR_VERSION
48+
49+
2750
@blp.get("/index")
2851
@blp.get("/index/<string:source_prefix>")
2952
def index(source_prefix: Optional[str] = None) -> Response:
@@ -39,6 +62,7 @@ def index(source_prefix: Optional[str] = None) -> Response:
3962
the source index into 16 shards. (Non-source metadata is not filtered by
4063
the prefix and is always returned.)
4164
"""
65+
minor = get_request_minor_version()
4266
index = Index()
4367

4468
source_query: EagerQuery = eager_query("Source")
@@ -53,16 +77,23 @@ def index(source_prefix: Optional[str] = None) -> Response:
5377
source_query = source_query.filter(Source.uuid.startswith(source_prefix))
5478

5579
for source in source_query.all():
56-
index.sources[source.uuid] = json_version(source.to_api_v2())
80+
index.sources[source.uuid] = json_version(source.to_api_v2(minor))
5781
for item in source.collection:
58-
index.items[item.uuid] = json_version(item.to_api_v2())
82+
index.items[item.uuid] = json_version(item.to_api_v2(minor))
5983

6084
journalist_query: EagerQuery = eager_query("Journalist")
6185
for journalist in journalist_query.all():
62-
index.journalists[journalist.uuid] = json_version(journalist.to_api_v2())
86+
index.journalists[journalist.uuid] = json_version(journalist.to_api_v2(minor))
6387

64-
version = json_version(asdict(index))
65-
response = jsonify(asdict(index))
88+
# We want to enforce the *current* shape of `Index`, so we should wait until
89+
# we have the dictionary representation to delete top-level keys unsupported
90+
# by the current minor version.
91+
index_dict = asdict(index)
92+
if minor < 1:
93+
del index_dict["journalists"]
94+
95+
version = json_version(index_dict)
96+
response = jsonify(index_dict)
6697

6798
# If the request's `If-None-Match` header matches the version,
6899
# return HTTP 304 with an empty response.
@@ -94,9 +125,12 @@ def data() -> Response:
94125
except (TypeError, ValueError) as exc:
95126
abort(422, f"malformed request; {exc}")
96127

128+
minor = get_request_minor_version()
97129
response = BatchResponse()
98130

99-
if requested.events:
131+
if minor < 3 and requested.events:
132+
abort(400, "Events are not supported for API minor version < 3")
133+
if minor >= 3 and requested.events:
100134
if len(requested.events) > EVENTS_MAX:
101135
abort(429, f"a BatchRequest MUST NOT include more than {EVENTS_MAX} events")
102136

@@ -114,11 +148,11 @@ def data() -> Response:
114148

115149
# Process events in snowflake order.
116150
for event in sorted(events, key=lambda e: int(e.id)):
117-
result = handler.process(event)
151+
result = handler.process(event, minor)
118152
for uuid, source in result.sources.items():
119-
response.sources[uuid] = source.to_api_v2() if source is not None else None
153+
response.sources[uuid] = source.to_api_v2(minor) if source is not None else None
120154
for uuid, item in result.items.items():
121-
response.items[uuid] = item.to_api_v2() if item is not None else None
155+
response.items[uuid] = item.to_api_v2(minor) if item is not None else None
122156
response.events[result.event_id] = result.status
123157

124158
# The set of items (UUIDs) that were emitted by processed events.
@@ -127,7 +161,7 @@ def data() -> Response:
127161
if requested.sources:
128162
source_query: EagerQuery = eager_query("Source")
129163
for source in source_query.filter(Source.uuid.in_(str(uuid) for uuid in requested.sources)):
130-
response.sources[source.uuid] = source.to_api_v2()
164+
response.sources[source.uuid] = source.to_api_v2(minor)
131165

132166
if requested.items:
133167
# If an item was explicitly requested but was already emitted by a
@@ -138,7 +172,7 @@ def data() -> Response:
138172
for item in submission_query.filter(
139173
Submission.uuid.in_(str(uuid) for uuid in left_to_read)
140174
):
141-
response.items[item.uuid] = item.to_api_v2()
175+
response.items[item.uuid] = item.to_api_v2(minor)
142176

143177
reply_query: EagerQuery = eager_query("Reply")
144178
for item in reply_query.filter(Reply.uuid.in_(str(uuid) for uuid in left_to_read)):
@@ -147,13 +181,23 @@ def data() -> Response:
147181
# `Submission` and `Reply` tables. This is vanishingly unlikely,
148182
# but SQLite can't enforce uniqueness between them.
149183
raise MultipleResultsFound(f"found {item.uuid} in both submissions and replies")
150-
response.items[item.uuid] = item.to_api_v2()
184+
response.items[item.uuid] = item.to_api_v2(minor)
151185

152186
if requested.journalists:
153187
journalist_query: EagerQuery = eager_query("Journalist")
154188
for journalist in journalist_query.filter(
155189
Journalist.uuid.in_(str(uuid) for uuid in requested.journalists)
156190
):
157-
response.journalists[journalist.uuid] = journalist.to_api_v2()
191+
response.journalists[journalist.uuid] = journalist.to_api_v2(minor)
192+
193+
response_dict = asdict(response)
194+
195+
# We want to enforce the *current* shape of `BatchResponse`, so we should
196+
# wait until we have the dictionary representation to delete top-level keys
197+
# unsupported by the current minor version.
198+
if minor < 1:
199+
del response_dict["journalists"]
200+
if minor < 3:
201+
del response_dict["events"]
158202

159-
return jsonify(asdict(response))
203+
return jsonify(response_dict)

securedrop/journalist_app/api2/events.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,17 @@ class EventHandler:
4141
"""
4242

4343
def __init__(self, session: Session, redis: Redis) -> None:
44+
"""
45+
Configure the `EventHandler`. Attributes set here are for internal use
46+
by the `EventHandler`; handler methods are static and do not have access
47+
to them, which means they cannot influence the processing of a given
48+
event.
49+
"""
50+
4451
self._session = session
4552
self._redis = redis
4653

47-
def process(self, event: Event) -> EventResult:
54+
def process(self, event: Event, minor: int) -> EventResult:
4855
"""The per-event entry-point for handling a single event."""
4956

5057
try:
@@ -73,7 +80,7 @@ def process(self, event: Event) -> EventResult:
7380
)
7481

7582
self.mark_progress(event) # prevent races
76-
result = handler(event)
83+
result = handler(event, minor)
7784
self.mark_progress(event, result.status[0]) # enforce idempotence
7885
return result
7986

@@ -103,7 +110,7 @@ def mark_progress(
103110
)
104111

105112
@staticmethod
106-
def handle_item_deleted(event: Event) -> EventResult:
113+
def handle_item_deleted(event: Event, minor: int) -> EventResult:
107114
item = find_item(event.target.item_uuid)
108115
if item is None:
109116
return EventResult(
@@ -119,7 +126,7 @@ def handle_item_deleted(event: Event) -> EventResult:
119126
)
120127

121128
@staticmethod
122-
def handle_reply_sent(event: Event) -> EventResult:
129+
def handle_reply_sent(event: Event, minor: int) -> EventResult:
123130
try:
124131
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
125132
except NoResultFound:
@@ -142,7 +149,7 @@ def handle_reply_sent(event: Event) -> EventResult:
142149
)
143150

144151
@staticmethod
145-
def handle_source_deleted(event: Event) -> EventResult:
152+
def handle_source_deleted(event: Event, minor: int) -> EventResult:
146153
try:
147154
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
148155
except NoResultFound:
@@ -154,7 +161,7 @@ def handle_source_deleted(event: Event) -> EventResult:
154161
),
155162
)
156163

157-
current_version = json_version(source.to_api_v2())
164+
current_version = json_version(source.to_api_v2(minor))
158165
if event.target.version != current_version:
159166
return EventResult(
160167
event_id=event.id,
@@ -176,7 +183,7 @@ def handle_source_deleted(event: Event) -> EventResult:
176183
)
177184

178185
@staticmethod
179-
def handle_source_conversation_deleted(event: Event) -> EventResult:
186+
def handle_source_conversation_deleted(event: Event, minor: int) -> EventResult:
180187
try:
181188
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
182189
except NoResultFound:
@@ -188,7 +195,7 @@ def handle_source_conversation_deleted(event: Event) -> EventResult:
188195
),
189196
)
190197

191-
current_version = json_version(source.to_api_v2())
198+
current_version = json_version(source.to_api_v2(minor))
192199
if event.target.version != current_version:
193200
return EventResult(
194201
event_id=event.id,
@@ -212,7 +219,7 @@ def handle_source_conversation_deleted(event: Event) -> EventResult:
212219
)
213220

214221
@staticmethod
215-
def handle_source_starred(event: Event) -> EventResult:
222+
def handle_source_starred(event: Event, minor: int) -> EventResult:
216223
try:
217224
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
218225
except NoResultFound:
@@ -235,7 +242,7 @@ def handle_source_starred(event: Event) -> EventResult:
235242
)
236243

237244
@staticmethod
238-
def handle_source_unstarred(event: Event) -> EventResult:
245+
def handle_source_unstarred(event: Event, minor: int) -> EventResult:
239246
try:
240247
source = Source.query.filter(Source.uuid == event.target.source_uuid).one()
241248
except NoResultFound:
@@ -258,7 +265,7 @@ def handle_source_unstarred(event: Event) -> EventResult:
258265
)
259266

260267
@staticmethod
261-
def handle_item_seen(event: Event) -> EventResult:
268+
def handle_item_seen(event: Event, minor: int) -> EventResult:
262269
item = find_item(event.target.item_uuid)
263270
if item is None:
264271
return EventResult(

securedrop/models.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def public_key(self) -> Optional[str]:
182182
except GpgKeyNotFoundError:
183183
return None
184184

185-
def to_api_v2(self) -> Dict[str, Any]:
185+
def to_api_v2(self, minor: int) -> Dict[str, Any]:
186186
if self.last_updated:
187187
last_updated = self.last_updated
188188
else:
@@ -191,7 +191,7 @@ def to_api_v2(self) -> Dict[str, Any]:
191191
starred = bool(self.star and self.star.starred)
192192
collection = {}
193193
for item in self.collection:
194-
collection[item.uuid] = item.to_api_v2()
194+
collection[item.uuid] = item.to_api_v2(minor)
195195

196196
return {
197197
"uuid": self.uuid,
@@ -287,7 +287,7 @@ def is_file(self) -> bool:
287287
def is_message(self) -> bool:
288288
return self.filename.endswith("msg.gpg")
289289

290-
def to_api_v2(self) -> Dict[str, Any]:
290+
def to_api_v2(self, minor: int) -> Dict[str, Any]:
291291
if self.is_file:
292292
seen_by = [f.journalist.uuid for f in self.seen_files if f.journalist]
293293
else: # is_message
@@ -297,17 +297,21 @@ def to_api_v2(self) -> Dict[str, Any]:
297297
# (format: {interaction_count}-{journalist_filename}-*)
298298
interaction_count = int(self.filename.split("-")[0])
299299

300-
return {
300+
data = {
301301
"kind": "file" if self.is_file else "message",
302302
"uuid": self.uuid,
303303
"source": self.source.uuid,
304304
"size": self.size,
305305
# TODO: how is this different from seen_by?
306306
"is_read": self.seen,
307307
"seen_by": seen_by,
308-
"interaction_count": interaction_count,
309308
}
310309

310+
if minor >= 2:
311+
data["interaction_count"] = interaction_count
312+
313+
return data
314+
311315
def to_api_v1(self) -> "Dict[str, Any]":
312316
seen_by = {
313317
f.journalist.uuid
@@ -405,22 +409,26 @@ def query_options(cls, base: Optional[Load] = None) -> Tuple[Load, ...]:
405409
base.joinedload(cls.seen_replies).joinedload(SeenReply.journalist), # type: ignore[attr-defined]
406410
)
407411

408-
def to_api_v2(self) -> Dict[str, Any]:
412+
def to_api_v2(self, minor: int) -> Dict[str, Any]:
409413
# Extract interaction_count from filename
410414
# (format: {interaction_count}-{journalist_filename}-reply.gpg)
411415
interaction_count = int(self.filename.split("-")[0])
412416

413-
return {
417+
data = {
414418
"kind": "reply",
415419
"uuid": self.uuid,
416420
"source": self.source.uuid,
417421
"size": self.size,
418422
"journalist_uuid": self.journalist.uuid,
419423
"is_deleted_by_source": self.deleted_by_source,
420424
"seen_by": [r.journalist.uuid for r in self.seen_replies],
421-
"interaction_count": interaction_count,
422425
}
423426

427+
if minor >= 2:
428+
data["interaction_count"] = interaction_count
429+
430+
return data
431+
424432
def to_api_v1(self) -> "Dict[str, Any]":
425433
seen_by = [r.journalist.uuid for r in SeenReply.query.filter(SeenReply.reply_id == self.id)]
426434
return {
@@ -849,7 +857,7 @@ def to_api_v1(self, all_info: bool = True) -> Dict[str, Any]:
849857

850858
return json_user
851859

852-
def to_api_v2(self) -> Dict[str, Any]:
860+
def to_api_v2(self, minor: int) -> Dict[str, Any]:
853861
return {
854862
"username": self.username,
855863
"uuid": self.uuid,

0 commit comments

Comments
 (0)