2424PREFIX_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>" )
2952def 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 )
0 commit comments