Skip to content

Commit 6b6d33d

Browse files
authored
Merge 1763776 into 052c556
2 parents 052c556 + 1763776 commit 6b6d33d

File tree

12 files changed

+205
-137
lines changed

12 files changed

+205
-137
lines changed

src/pyff/api.py

Lines changed: 89 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib
22
import threading
33
from datetime import datetime, timedelta
4+
from enum import Enum
45
from json import dumps
56
from typing import Any, Dict, Generator, Iterable, List, Mapping, Optional, Tuple
67

@@ -21,7 +22,7 @@
2122
from pyff.constants import config
2223
from pyff.exceptions import ResourceException
2324
from pyff.logs import get_log
24-
from pyff.pipes import plumbing
25+
from pyff.pipes import PipeState, plumbing
2526
from pyff.repo import MDRepository
2627
from pyff.resource import Resource
2728
from 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

src/pyff/builtins.py

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from copy import deepcopy
1414
from datetime import datetime
1515
from distutils.util import strtobool
16-
from typing import Dict, Optional
16+
from typing import Any, Dict, Iterable, List, Optional
1717

1818
import ipaddr
1919
import six
@@ -274,10 +274,10 @@ def fork(req: Plumbing.Request, *opts):
274274

275275
@deprecated(reason="any pipeline has been replace by other behaviour")
276276
@pipe(name='any')
277-
def _any(lst, d):
277+
def _any(lst: Iterable[Any], d: Any) -> Any:
278278
for x in lst:
279279
if x in d:
280-
if type(d) == dict:
280+
if isinstance(d, dict):
281281
return d[x]
282282
else:
283283
return True
@@ -383,14 +383,20 @@ def when(req: Plumbing.Request, condition: str, *values):
383383
The condition operates on the state: if 'foo' is present in the state (with any value), then the something branch is
384384
followed. If 'bar' is present in the state with the value 'bill' then the other branch is followed.
385385
"""
386-
c = req.state.get(condition, None)
387-
if c is None:
388-
log.debug(f'Condition {repr(condition)} not present in state {req.state}')
386+
log.debug(f'"when" called for condition "{condition}", values {values}, state {req.state}')
387+
c: Any = None
388+
if condition in req.state.conditions:
389+
c = True
390+
if condition == 'accept':
391+
c = req.state.accept
389392
if c is not None and (not values or _any(values, c)):
390393
if not isinstance(req.args, list):
391394
raise ValueError('Non-list arguments to "when" not allowed')
392395

393-
return Plumbing(pipeline=req.args, pid="%s.when" % req.plumbing.id).iprocess(req)
396+
_pid = f'{req.plumbing.id}.when'
397+
log.debug(f'Creating new plumbing: {_pid}, pipeline {req.args}')
398+
return Plumbing(pipeline=req.args, pid=_pid).iprocess(req)
399+
log.debug(f'Continuing on plumbing {req.id}')
394400
return req.t
395401

396402

@@ -506,7 +512,7 @@ def publish(req: Plumbing.Request, *opts):
506512
try:
507513
validate_document(req.t)
508514
except DocumentInvalid as ex:
509-
log.error(ex.error_log)
515+
log.error(f'Got an error from validate_document {ex.error_log}')
510516
raise PipeException("XML schema validation failed")
511517

512518
def _nop(x):
@@ -679,18 +685,35 @@ def load(req: Plumbing.Request, *opts):
679685
req.md.rm.reload(fail_on_error=bool(_opts['fail_on_error']))
680686

681687

682-
def _select_args(req):
683-
args = req.args
684-
if args is None and 'select' in req.state:
685-
args = [req.state.get('select')]
686-
if args is None:
688+
def _select_args(req: Plumbing.Request) -> List[str]:
689+
log.debug(f'Select args: {req.args}, state: {req.state}')
690+
args: List[str] = []
691+
692+
if req.args:
693+
for this in req.args:
694+
if not isinstance(this, str):
695+
raise ValueError(f'Selection not possible with arg that is not a string: {this}')
696+
args += [this]
697+
698+
if not args and req.state.select:
699+
args = [req.state.select]
700+
log.debug(f'Using req.state.select: {args}')
701+
if not args:
687702
args = req.store.collections()
688-
if args is None or not args:
703+
log.debug(f'Using req.store.collections: {args}')
704+
if not args:
689705
args = req.store.lookup('entities')
690-
if args is None or not args:
706+
if len(args) < 5:
707+
log.debug(f'Using req.store.entities: {args}')
708+
else:
709+
log.debug(f'Using req.store.entities: {args[:4]} (truncated)')
710+
if not args:
691711
args = []
692712

693-
log.info("selecting using args: %s" % args)
713+
if len(args) < 5:
714+
log.info(f'selecting using args: {args}')
715+
else:
716+
log.info(f'selecting using args: {args[:4]} (truncated)')
694717

695718
return args
696719

@@ -768,9 +791,9 @@ def select(req: Plumbing.Request, *opts):
768791

769792
entities = resolve_entities(args, lookup_fn=req.md.store.select)
770793

771-
if req.state.get('match', None): # TODO - allow this to be passed in via normal arguments
794+
if req.state.match: # TODO - allow this to be passed in via normal arguments
772795

773-
match = req.state['match']
796+
match = req.state.match
774797

775798
if isinstance(match, six.string_types):
776799
query = [match.lower()]
@@ -1435,11 +1458,11 @@ def emit(req: Plumbing.Request, ctype="application/xml", *opts):
14351458
if not isinstance(d, six.binary_type):
14361459
d = d.encode("utf-8")
14371460
m.update(d)
1438-
req.state['headers']['ETag'] = m.hexdigest()
1461+
req.state.headers['ETag'] = m.hexdigest()
14391462
else:
14401463
raise PipeException("Empty")
14411464

1442-
req.state['headers']['Content-Type'] = ctype
1465+
req.state.headers['Content-Type'] = ctype
14431466
if six.PY2:
14441467
d = six.u(d)
14451468
return d
@@ -1517,7 +1540,7 @@ def finalize(req: Plumbing.Request, *opts):
15171540
if name is None or 0 == len(name):
15181541
name = req.args.get('Name', None)
15191542
if name is None or 0 == len(name):
1520-
name = req.state.get('url', None)
1543+
name = req.state.url
15211544
if name and 'baseURL' in req.args:
15221545

15231546
try:
@@ -1569,7 +1592,7 @@ def finalize(req: Plumbing.Request, *opts):
15691592
# TODO: offset can be None here, if validUntil is not a valid duration or ISO date
15701593
# What is the right action to take then?
15711594
if offset:
1572-
req.state['cache'] = int(total_seconds(offset) / 50)
1595+
req.state.cache = int(total_seconds(offset) / 50)
15731596

15741597
cache_duration = req.args.get('cacheDuration', e.get('cacheDuration', None))
15751598
if cache_duration is not None and len(cache_duration) > 0:
@@ -1578,7 +1601,7 @@ def finalize(req: Plumbing.Request, *opts):
15781601
raise PipeException("Unable to parse %s as xs:duration" % cache_duration)
15791602

15801603
e.set('cacheDuration', cache_duration)
1581-
req.state['cache'] = int(total_seconds(offset))
1604+
req.state.cache = int(total_seconds(offset))
15821605

15831606
return req.t
15841607

src/pyff/md.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import traceback
99

1010
from pyff.constants import config, parse_options
11-
from pyff.pipes import plumbing
11+
from pyff.pipes import PipeState, plumbing
1212
from pyff.repo import MDRepository
1313

1414

@@ -30,7 +30,7 @@ def main():
3030
try:
3131
md = MDRepository()
3232
for p in args:
33-
plumbing(p).process(md, state={'batch': True, 'stats': {}})
33+
plumbing(p).process(md, state=PipeState(conditions={'batch'}))
3434
sys.exit(0)
3535
except Exception as ex:
3636
logging.debug(traceback.format_exc())

0 commit comments

Comments
 (0)