11import importlib
22import threading
33from datetime import datetime , timedelta
4+ from enum import Enum
45from json import dumps
56from typing import Any , Dict , Generator , Iterable , List , Mapping , Optional , Tuple
67
2122from pyff .constants import config
2223from pyff .exceptions import ResourceException
2324from pyff .logs import get_log
24- from pyff .pipes import plumbing
25+ from pyff .pipes import PipeState , plumbing
2526from pyff .repo import MDRepository
2627from pyff .resource import Resource
2728from pyff .samlmd import entity_display_name
@@ -153,16 +154,36 @@ def request_handler(request: Request) -> Response:
153154 return r
154155
155156
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 ]]:
157166 """
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 .
159168
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.
162183 """
163184 _ctypes = {'xml' : 'application/samlmetadata+xml;application/xml;text/xml' , 'json' : 'application/json' }
164185
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 ]]:
166187 """ Split a path into a base component and an extension. """
167188 if x is not None :
168189 x = x .strip ()
@@ -178,6 +199,45 @@ def _d(x: Optional[str], do_split: bool = True) -> Tuple[Optional[str], Optional
178199
179200 return x , None
180201
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+ """
181241 log .debug (f'Processing request: { request } ' )
182242
183243 if request .matchdict is None :
@@ -215,83 +275,45 @@ def _d(x: Optional[str], do_split: bool = True) -> Tuple[Optional[str], Optional
215275 if pfx is None :
216276 raise exc .exception_response (404 )
217277
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
255286
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 )
262288
263289 try :
264- accepter = MediaAccept (accept )
265290 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+ )
276301
277302 r = p .process (request .registry .md , state = state , raise_exceptions = True , scheduler = request .registry .scheduler )
278- log .debug (f'Plumbing process result: { r } ' )
279303 if r is None :
280304 r = []
281305
282306 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 )
286309 if not ctype :
287- r , t = _fmt (r , accepter )
310+ r , t = _fmt (r , accept )
288311 ctype = t
289312
290313 response .text = b2u (r )
291314 response .size = len (r )
292315 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 )
295317 return response
296318 except ResourceException as ex :
297319 import traceback
0 commit comments