1
1
import importlib
2
2
import threading
3
3
from datetime import datetime , timedelta
4
+ from enum import Enum
4
5
from json import dumps
5
6
from typing import Any , Dict , Generator , Iterable , List , Mapping , Optional , Tuple
6
7
21
22
from pyff .constants import config
22
23
from pyff .exceptions import ResourceException
23
24
from pyff .logs import get_log
24
- from pyff .pipes import plumbing
25
+ from pyff .pipes import PipeState , plumbing
25
26
from pyff .repo import MDRepository
26
27
from pyff .resource import Resource
27
28
from pyff .samlmd import entity_display_name
@@ -153,16 +154,36 @@ def request_handler(request: Request) -> Response:
153
154
return r
154
155
155
156
156
- def process_handler (request : Request ) -> Response :
157
+ class ContentNegPolicy (Enum ):
158
+ extension = 'extension' # current default
159
+ adaptive = 'adaptive'
160
+ header = 'header' # future default
161
+
162
+
163
+ def _process_content_negotiate (
164
+ policy : ContentNegPolicy , alias : str , path : Optional [str ], pfx , request : Request
165
+ ) -> Tuple [MediaAccept , Optional [str ], Optional [str ]]:
157
166
"""
158
- The main request handler for pyFF. Implements API call hooks and content negotiation .
167
+ Determine requested content type, based on policy, Accept request header and path extension .
159
168
160
- :param request: the HTTP request object
161
- :return: the data to send to the client
169
+ content_negotiation_policy is one of three values:
170
+
171
+ 1. extension - current default, inspect the path and if it ends in
172
+ an extension, e.g. .xml or .json, always strip off the extension to
173
+ get the entityID and if no accept header or a wildcard header, then
174
+ use the extension to determine the return Content-Type.
175
+
176
+ 2. adaptive - only if no accept header or if a wildcard, then inspect
177
+ the path and if it ends in an extension strip off the extension to
178
+ get the entityID and use the extension to determine the return
179
+ Content-Type.
180
+
181
+ 3. header - future default, do not inspect the path for an extension and
182
+ use only the Accept header to determine the return Content-Type.
162
183
"""
163
184
_ctypes = {'xml' : 'application/samlmetadata+xml;application/xml;text/xml' , 'json' : 'application/json' }
164
185
165
- def _d (x : Optional [str ], do_split : bool = True ) -> Tuple [Optional [str ], Optional [str ]]:
186
+ def _split_path (x : Optional [str ], do_split : bool = True ) -> Tuple [Optional [str ], Optional [str ]]:
166
187
""" Split a path into a base component and an extension. """
167
188
if x is not None :
168
189
x = x .strip ()
@@ -178,6 +199,45 @@ def _d(x: Optional[str], do_split: bool = True) -> Tuple[Optional[str], Optional
178
199
179
200
return x , None
180
201
202
+ # TODO - sometimes the client sends > 1 accept header value with ','.
203
+ accept = str (request .accept ).split (',' )[0 ]
204
+ valid_accept = accept and not ('application/*' in accept or 'text/*' in accept or '*/*' in accept )
205
+
206
+ path_no_extension , extension = _split_path (path , True )
207
+ accept_from_extension = accept
208
+ if extension :
209
+ accept_from_extension = _ctypes .get (extension , accept )
210
+
211
+ if policy == ContentNegPolicy .extension :
212
+ path = path_no_extension
213
+ if not valid_accept :
214
+ accept = accept_from_extension
215
+ elif policy == ContentNegPolicy .adaptive :
216
+ if not valid_accept :
217
+ path = path_no_extension
218
+ accept = accept_from_extension
219
+
220
+ if not accept :
221
+ log .warning ('Could not determine accepted response type' )
222
+ raise exc .exception_response (400 )
223
+
224
+ q : Optional [str ]
225
+ if pfx and path :
226
+ q = f'{{{ pfx } }}{ path } '
227
+ path = f'/{ alias } /{ path } '
228
+ else :
229
+ q = path
230
+
231
+ return MediaAccept (accept ), path , q
232
+
233
+
234
+ def process_handler (request : Request ) -> Response :
235
+ """
236
+ The main request handler for pyFF. Implements API call hooks and content negotiation.
237
+
238
+ :param request: the HTTP request object
239
+ :return: the data to send to the client
240
+ """
181
241
log .debug (f'Processing request: { request } ' )
182
242
183
243
if request .matchdict is None :
@@ -215,83 +275,45 @@ def _d(x: Optional[str], do_split: bool = True) -> Tuple[Optional[str], Optional
215
275
if pfx is None :
216
276
raise exc .exception_response (404 )
217
277
218
- # content_negotiation_policy is one of three values:
219
- # 1. extension - current default, inspect the path and if it ends in
220
- # an extension, e.g. .xml or .json, always strip off the extension to
221
- # get the entityID and if no accept header or a wildcard header, then
222
- # use the extension to determine the return Content-Type.
223
- #
224
- # 2. adaptive - only if no accept header or if a wildcard, then inspect
225
- # the path and if it ends in an extension strip off the extension to
226
- # get the entityID and use the extension to determine the return
227
- # Content-Type.
228
- #
229
- # 3. header - future default, do not inspect the path for an extension and
230
- # use only the Accept header to determine the return Content-Type.
231
- policy = config .content_negotiation_policy
232
-
233
- # TODO - sometimes the client sends > 1 accept header value with ','.
234
- accept = str (request .accept ).split (',' )[0 ]
235
- valid_accept = accept and not ('application/*' in accept or 'text/*' in accept or '*/*' in accept )
236
-
237
- new_path : Optional [str ] = path
238
- path_no_extension , extension = _d (new_path , True )
239
- accept_from_extension = accept
240
- if extension :
241
- accept_from_extension = _ctypes .get (extension , accept )
242
-
243
- if policy == 'extension' :
244
- new_path = path_no_extension
245
- if not valid_accept :
246
- accept = accept_from_extension
247
- elif policy == 'adaptive' :
248
- if not valid_accept :
249
- new_path = path_no_extension
250
- accept = accept_from_extension
251
-
252
- if not accept :
253
- log .warning ('Could not determine accepted response type' )
254
- raise exc .exception_response (400 )
278
+ try :
279
+ policy = ContentNegPolicy (config .content_negotiation_policy )
280
+ except ValueError :
281
+ log .debug (
282
+ f'Invalid value for config.content_negotiation_policy: { config .content_negotiation_policy } , '
283
+ f'defaulting to "extension"'
284
+ )
285
+ policy = ContentNegPolicy .extension
255
286
256
- q : Optional [str ]
257
- if pfx and new_path :
258
- q = f'{{{ pfx } }}{ new_path } '
259
- new_path = f'/{ alias } /{ new_path } '
260
- else :
261
- q = new_path
287
+ accept , new_path , q = _process_content_negotiate (policy , alias , path , pfx , request )
262
288
263
289
try :
264
- accepter = MediaAccept (accept )
265
290
for p in request .registry .plumbings :
266
- state = {
267
- entry : True ,
268
- ' headers' : {'Content-Type' : None },
269
- ' accept' : accepter ,
270
- ' url' : request .current_route_url (),
271
- ' select' : q ,
272
- ' match' : match .lower () if match else match ,
273
- ' path' : new_path ,
274
- ' stats' : {},
275
- }
291
+ state = PipeState (
292
+ conditions = { entry } ,
293
+ headers = {'Content-Type' : None },
294
+ accept = accept ,
295
+ url = request .current_route_url (),
296
+ select = q ,
297
+ match = match .lower () if match else match ,
298
+ path = new_path ,
299
+ stats = {},
300
+ )
276
301
277
302
r = p .process (request .registry .md , state = state , raise_exceptions = True , scheduler = request .registry .scheduler )
278
- log .debug (f'Plumbing process result: { r } ' )
279
303
if r is None :
280
304
r = []
281
305
282
306
response = Response ()
283
- _headers = state .get ('headers' , {})
284
- response .headers .update (_headers )
285
- ctype = _headers .get ('Content-Type' , None )
307
+ response .headers .update (state .headers )
308
+ ctype = state .headers .get ('Content-Type' , None )
286
309
if not ctype :
287
- r , t = _fmt (r , accepter )
310
+ r , t = _fmt (r , accept )
288
311
ctype = t
289
312
290
313
response .text = b2u (r )
291
314
response .size = len (r )
292
315
response .content_type = ctype
293
- cache_ttl = int (state .get ('cache' , 0 ))
294
- response .expires = datetime .now () + timedelta (seconds = cache_ttl )
316
+ response .expires = datetime .now () + timedelta (seconds = state .cache )
295
317
return response
296
318
except ResourceException as ex :
297
319
import traceback
0 commit comments