include cco.processor; cco.webapi: Python3 fixes
This commit is contained in:
parent
ee29ae7f9e
commit
d0dfb4a79c
20 changed files with 1466 additions and 1 deletions
|
@ -45,7 +45,7 @@ TAN entry form) is executed.
|
||||||
>>> req.setTraversalStack(['++auth++2factor'])
|
>>> req.setTraversalStack(['++auth++2factor'])
|
||||||
|
|
||||||
>>> scp.extractCredentials(req)
|
>>> scp.extractCredentials(req)
|
||||||
'2fa_tan_form.html?a=...&h=...&b=...'
|
'2fa_tan_form.html?h=...&a=...&b=...'
|
||||||
|
|
||||||
What if we enter data for authentication phase 2? No authentication
|
What if we enter data for authentication phase 2? No authentication
|
||||||
because the hashes don't match.
|
because the hashes don't match.
|
||||||
|
|
2
cco/processor/__init__.py
Normal file
2
cco/processor/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
""" package cco.processor
|
||||||
|
"""
|
28
cco/processor/common.py
Normal file
28
cco/processor/common.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
#
|
||||||
|
# cco.processor.common
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Common stuff for the cco.processor package
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Error(object):
|
||||||
|
|
||||||
|
def __init__(self, msg):
|
||||||
|
self.message = msg
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '<Error %r>' % self.message
|
||||||
|
|
||||||
|
__repr__ = __str__
|
||||||
|
|
||||||
|
def error(msg):
|
||||||
|
return Error(msg)
|
||||||
|
|
||||||
|
# TODO: create a class (with __repr__() method) for _not_found and _invalid
|
||||||
|
|
||||||
|
_not_found = object()
|
||||||
|
_invalid = object()
|
||||||
|
|
||||||
|
|
33
cco/processor/controller.py
Normal file
33
cco/processor/controller.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
#
|
||||||
|
# cco.processor.controller
|
||||||
|
#
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
logger = getLogger('cco.processor.controller')
|
||||||
|
|
||||||
|
|
||||||
|
def loop(jobname, fct, data, skip=0, limit=None,
|
||||||
|
bsize=10, action=None):
|
||||||
|
logger.info('loop %s starting' % jobname)
|
||||||
|
result = dict(count=0, created=0, updated=0, error=0)
|
||||||
|
if skip > 0:
|
||||||
|
start = skip - 1
|
||||||
|
for i, row in enumerate(data):
|
||||||
|
if i >= start:
|
||||||
|
break
|
||||||
|
for row in data:
|
||||||
|
r = fct(row)
|
||||||
|
result['count'] += 1
|
||||||
|
if r is not None:
|
||||||
|
result[r] += 1
|
||||||
|
if result['count'] % bsize == 0:
|
||||||
|
logger.info('loop %s: %s' % (jobname, result))
|
||||||
|
if action is not None:
|
||||||
|
action()
|
||||||
|
if limit and result['count'] >= limit:
|
||||||
|
break
|
||||||
|
if action is not None:
|
||||||
|
action()
|
||||||
|
logger.info('loop %s finished: %s' % (jobname, result))
|
||||||
|
return result
|
80
cco/processor/hook.py
Normal file
80
cco/processor/hook.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
#
|
||||||
|
# cco.processor.hook
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Mixin classes and other stuff for hooking into loops (Zope3, Bluebream)
|
||||||
|
objects so that certain operations (like setting and retrieving attributes)
|
||||||
|
can be handled by services like storage or notifiers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
import traceback
|
||||||
|
import transaction
|
||||||
|
|
||||||
|
from cco.processor.common import _not_found
|
||||||
|
|
||||||
|
from loops import common
|
||||||
|
|
||||||
|
logger = getLogger('cco.processor.hook')
|
||||||
|
|
||||||
|
loader_hooks = {}
|
||||||
|
processor_hooks = {}
|
||||||
|
|
||||||
|
|
||||||
|
def loadData(obj):
|
||||||
|
#print 'loadData ***', obj.context.__name__
|
||||||
|
data = {}
|
||||||
|
for hook in obj._hook_loaders:
|
||||||
|
fct = loader_hooks.get(hook)
|
||||||
|
try:
|
||||||
|
fct(obj, data)
|
||||||
|
except:
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def processData(obj, data):
|
||||||
|
#print 'processData ***', obj.context.__name__
|
||||||
|
for hook in obj._hook_processors:
|
||||||
|
fct = processor_hooks.get(hook)
|
||||||
|
try:
|
||||||
|
fct(obj, data)
|
||||||
|
except:
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterBase(common.AdapterBase):
|
||||||
|
|
||||||
|
_hook_message_base = 'cco/data/dummy'
|
||||||
|
_hook_loaders = []
|
||||||
|
_hook_processors = []
|
||||||
|
_hook_config = {}
|
||||||
|
|
||||||
|
_old_data = None
|
||||||
|
_cont = None
|
||||||
|
_id = None
|
||||||
|
|
||||||
|
def __init__(self, context):
|
||||||
|
super(AdapterBase, self).__init__(context)
|
||||||
|
object.__setattr__(self, '_new_data', {})
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
value = self._new_data.get(attr, _not_found)
|
||||||
|
if value is _not_found:
|
||||||
|
if self._old_data is None:
|
||||||
|
object.__setattr__(self, '_old_data', loadData(self))
|
||||||
|
value = self._old_data.get(attr, _not_found)
|
||||||
|
if value is _not_found:
|
||||||
|
value = super(AdapterBase, self).__getattr__(attr)
|
||||||
|
self._old_data[attr] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
super(AdapterBase, self).__setattr__(attr, value)
|
||||||
|
if attr.startswith('__') or attr in self._adapterAttributes:
|
||||||
|
return
|
||||||
|
if not self._new_data:
|
||||||
|
tr = transaction.manager.get()
|
||||||
|
tr.addBeforeCommitHook(processData, [self, self._new_data], {})
|
||||||
|
self._new_data[attr] = value
|
15
cco/processor/importer.py
Normal file
15
cco/processor/importer.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#
|
||||||
|
# cco.processor.importer
|
||||||
|
#
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
|
||||||
|
def import_csv(import_dir, fn):
|
||||||
|
path = join(import_dir, fn)
|
||||||
|
with open(path) as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
yield row
|
||||||
|
|
109
cco/processor/storage.py
Normal file
109
cco/processor/storage.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
#
|
||||||
|
# cco.processor.storage
|
||||||
|
#
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
from zope.event import notify
|
||||||
|
from zope.lifecycleevent import ObjectModifiedEvent
|
||||||
|
from zope.traversing.api import getName
|
||||||
|
|
||||||
|
from loops.common import adapted, baseObject
|
||||||
|
from loops.concept import Concept
|
||||||
|
from loops.setup import addAndConfigureObject
|
||||||
|
from loops import util
|
||||||
|
|
||||||
|
from cco.processor.common import Error, _invalid
|
||||||
|
|
||||||
|
logger = getLogger('cco.processor.storage')
|
||||||
|
|
||||||
|
|
||||||
|
# check attributes, collect changes
|
||||||
|
|
||||||
|
def check_change(obj, attr, newValue, includeOnly=None, omit=[], updateEmpty=[]):
|
||||||
|
if attr.startswith('_') or attr in omit or newValue in (_invalid, None):
|
||||||
|
return None
|
||||||
|
if includeOnly is not None and attr not in includeOnly:
|
||||||
|
return None
|
||||||
|
oldValue = None
|
||||||
|
if obj is not None: # called from create_object
|
||||||
|
oldValue = getattr(obj, attr)
|
||||||
|
if isinstance(newValue, list) and oldValue:
|
||||||
|
oldValue = list(oldValue)
|
||||||
|
if newValue == oldValue or (oldValue and attr in updateEmpty):
|
||||||
|
return None
|
||||||
|
return (attr, (oldValue, newValue))
|
||||||
|
|
||||||
|
def collect_changes(obj, data, includeOnly=None, omit=[], updateEmpty=[]):
|
||||||
|
changes = [check_change(obj, attr, newValue, includeOnly, omit, updateEmpty)
|
||||||
|
for attr, newValue in data.items()]
|
||||||
|
return dict(c for c in changes if c is not None)
|
||||||
|
|
||||||
|
# access to persistent objects
|
||||||
|
|
||||||
|
def create_or_update_object(context, type_name, data,
|
||||||
|
includeOnly=None, omit=[], updateEmpty=[], dryRun=False):
|
||||||
|
obj = get_object(context, type_name, data)
|
||||||
|
if obj is None:
|
||||||
|
return create_object(context, type_name, data, includeOnly, omit, updateEmpty, dryRun)
|
||||||
|
else:
|
||||||
|
return update_object(obj, data, includeOnly, omit, updateEmpty, dryRun)
|
||||||
|
|
||||||
|
def create_object(context, type_name, data,
|
||||||
|
includeOnly=None, omit=[], updateEmpty=[], dryRun=False):
|
||||||
|
logCreate = data.get('_log_create', True)
|
||||||
|
ident = data.get('_identifier')
|
||||||
|
if logCreate:
|
||||||
|
logger.info('create_object %s %s: %s' % (type_name, ident, data))
|
||||||
|
changes = collect_changes(None, data, includeOnly, omit, updateEmpty)
|
||||||
|
msg = {'action': 'create', 'identifier': ident, 'changes': changes}
|
||||||
|
if dryRun:
|
||||||
|
return msg
|
||||||
|
type = adapted(context['concepts'][type_name])
|
||||||
|
cont = type.conceptManager or 'concepts'
|
||||||
|
name = (type.namePrefix or (type_name + '.')) + ident
|
||||||
|
attrs = {}
|
||||||
|
for attr, (ov, val) in changes.items():
|
||||||
|
if isinstance(val, Error):
|
||||||
|
logger.warn('create_object error: %s: %s %s' % (ident, attr, val))
|
||||||
|
msg['info'] = 'error'
|
||||||
|
return msg
|
||||||
|
attrs[attr] = val
|
||||||
|
obj = addAndConfigureObject(context[cont], Concept, name,
|
||||||
|
conceptType=baseObject(type), **attrs)
|
||||||
|
msg['uid'] = util.getUidForObject(obj)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def update_object(obj, data, includeOnly=None, omit=[], updateEmpty=[], dryRun=False):
|
||||||
|
logUpdate = data.get('_log_update', True)
|
||||||
|
ident = (data.get('_identifier') or
|
||||||
|
getattr(obj, 'identifier', getName(baseObject(obj))))
|
||||||
|
changes = collect_changes(obj, data, includeOnly, omit, updateEmpty)
|
||||||
|
msg = {'action': 'update', 'identifier': ident, 'uid': obj.uid, 'changes': changes}
|
||||||
|
if logUpdate and changes:
|
||||||
|
logger.info('update_object %s: %s' % (ident, changes))
|
||||||
|
if dryRun:
|
||||||
|
return msg
|
||||||
|
for attr, (ov, nv) in changes.items():
|
||||||
|
if isinstance(nv, Error):
|
||||||
|
logger.warn('update_object error: %s: %s %s' % (ident, attr, nv))
|
||||||
|
msg['info'] = 'error'
|
||||||
|
return msg
|
||||||
|
setattr(obj, attr, nv)
|
||||||
|
if changes:
|
||||||
|
notify(ObjectModifiedEvent(baseObject(obj)))
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def get_object(context, type_name, data):
|
||||||
|
if context is None:
|
||||||
|
logger.error('get_object %s: context not set, data: %s' % (type_name, data))
|
||||||
|
ident = data.get('_identifier')
|
||||||
|
if ident is None:
|
||||||
|
logger.warn('get_object %s: _identifier missing: %s' % (type_name, data))
|
||||||
|
type = adapted(context['concepts'][type_name])
|
||||||
|
cont = type.conceptManager or 'concepts'
|
||||||
|
name = (type.namePrefix or (type_name + '.')) + ident
|
||||||
|
ob = context[cont].get(name)
|
||||||
|
if ob:
|
||||||
|
return adapted(ob)
|
||||||
|
return None
|
||||||
|
|
93
cco/processor/transformer.py
Normal file
93
cco/processor/transformer.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
#
|
||||||
|
# cco.processor.transformer
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Transform a data structure (represented by a dictionary )
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from logging import getLogger
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from cco.processor.common import Error, error, _invalid, _not_found
|
||||||
|
|
||||||
|
logger = getLogger('cco.processor.transformer')
|
||||||
|
|
||||||
|
|
||||||
|
def transform(sdata, fmap, context=None):
|
||||||
|
tdata = {}
|
||||||
|
for tname, spec in fmap.items():
|
||||||
|
if spec is None:
|
||||||
|
tdata[tname] = None
|
||||||
|
continue
|
||||||
|
if isinstance(spec, str):
|
||||||
|
sname, modif = spec, None
|
||||||
|
else:
|
||||||
|
sname, modif = spec
|
||||||
|
if sname is None:
|
||||||
|
if modif is None:
|
||||||
|
tdata[tname] = None
|
||||||
|
continue
|
||||||
|
svalue = sdata # modif will extract values!
|
||||||
|
else:
|
||||||
|
svalue = sdata.get(sname, _not_found)
|
||||||
|
if svalue is _not_found:
|
||||||
|
#tdata[tname] = error('transform: not found: %s' % sname)
|
||||||
|
#continue
|
||||||
|
svalue = None
|
||||||
|
if modif is None:
|
||||||
|
tvalue = svalue
|
||||||
|
else:
|
||||||
|
tvalue = modify_value(modif, svalue, context)
|
||||||
|
tdata[tname] = tvalue
|
||||||
|
return tdata
|
||||||
|
|
||||||
|
def modify_value(fct, value, context=None):
|
||||||
|
try:
|
||||||
|
return fct(value, context)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
return fct(value)
|
||||||
|
except:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
return error('modify_value: %s, %s\n%s' % (fct, value, tb))
|
||||||
|
|
||||||
|
# predefined modifiers
|
||||||
|
|
||||||
|
def map_value(map):
|
||||||
|
def do_map(key):
|
||||||
|
v = map.get(key, _not_found)
|
||||||
|
if v is _not_found:
|
||||||
|
v = map.get('*', _not_found)
|
||||||
|
if v is _not_found:
|
||||||
|
return error('map_value: not found: %s' % key)
|
||||||
|
return v
|
||||||
|
return do_map
|
||||||
|
|
||||||
|
def const(val):
|
||||||
|
return lambda x: val
|
||||||
|
|
||||||
|
def int_inv(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, basestring) and val == '':
|
||||||
|
return _invalid
|
||||||
|
return int(val)
|
||||||
|
|
||||||
|
def float_inv(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, basestring) and val == '':
|
||||||
|
return _invalid
|
||||||
|
return float(val)
|
||||||
|
|
||||||
|
def iso_date(val, context=None, format='%Y-%m-%d'):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, date):
|
||||||
|
return val
|
||||||
|
if isinstance(val, basestring) and val == '':
|
||||||
|
return _invalid
|
||||||
|
return date(*(time.strptime(val[:10], format)[:3]))
|
207
cco/webapi/README.rst
Normal file
207
cco/webapi/README.rst
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
|
||||||
|
cco.webapi - cyberconcepts.org: Web API = REST + JSON
|
||||||
|
=====================================================
|
||||||
|
|
||||||
|
Let's first do some common imports and initializations.
|
||||||
|
|
||||||
|
>>> from zope.publisher.browser import TestRequest
|
||||||
|
>>> from zope.traversing.api import getName
|
||||||
|
|
||||||
|
>>> from logging import getLogger
|
||||||
|
>>> log = getLogger('cco.webapi')
|
||||||
|
|
||||||
|
>>> from cco.webapi.node import ApiNode
|
||||||
|
>>> from cco.webapi.tests import callPath, traverse
|
||||||
|
|
||||||
|
>>> from loops.setup import addAndConfigureObject, addObject
|
||||||
|
>>> from loops.concept import Concept
|
||||||
|
|
||||||
|
>>> concepts = loopsRoot['concepts']
|
||||||
|
>>> type_type = concepts['type']
|
||||||
|
>>> type_topic = addAndConfigureObject(concepts, Concept, 'topic',
|
||||||
|
... conceptType=type_type)
|
||||||
|
>>> type_task = addAndConfigureObject(concepts, Concept, 'task',
|
||||||
|
... conceptType=type_type)
|
||||||
|
>>> home = loopsRoot['views']['home']
|
||||||
|
|
||||||
|
We now create the first basic objects.
|
||||||
|
|
||||||
|
>>> apiRoot = addAndConfigureObject(home, ApiNode, 'webapi')
|
||||||
|
>>> node_topics = addAndConfigureObject(apiRoot, ApiNode, 'topics')
|
||||||
|
|
||||||
|
Querying the database with the GET method
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
We start with calling the API view of the top-level (root) API node.
|
||||||
|
|
||||||
|
>>> from cco.webapi.server import ApiHandler
|
||||||
|
>>> handler = ApiHandler(apiRoot, TestRequest())
|
||||||
|
>>> handler()
|
||||||
|
'[{"name": "topics"}]'
|
||||||
|
|
||||||
|
The tests module contains a shortcout for traversing a path and calling
|
||||||
|
the corresponding view.
|
||||||
|
|
||||||
|
>>> callPath(apiRoot)
|
||||||
|
'[{"name": "topics"}]'
|
||||||
|
|
||||||
|
What happens upon traversing a node?
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'topics')
|
||||||
|
'[]'
|
||||||
|
|
||||||
|
When a node does not exist we get a 'NotFound' exception.
|
||||||
|
|
||||||
|
>>> from zope.publisher.interfaces import NotFound
|
||||||
|
>>> try:
|
||||||
|
... callPath(apiRoot, 'topics/loops')
|
||||||
|
... except NotFound:
|
||||||
|
... print('NotFound error!')
|
||||||
|
NotFound error!
|
||||||
|
|
||||||
|
Maybe we should assign a target: we use the topic type as target
|
||||||
|
and create a 'loops' topic.
|
||||||
|
|
||||||
|
>>> node_topics.target = type_topic
|
||||||
|
>>> topic_loops = addAndConfigureObject(concepts, Concept, 'loops',
|
||||||
|
... conceptType=type_topic)
|
||||||
|
|
||||||
|
We now get a list of the target object's children.
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'topics')
|
||||||
|
'[{"name": "loops", "title": ""}]'
|
||||||
|
|
||||||
|
We can also directly access the target's children using their name.
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'topics/loops')
|
||||||
|
'{"name": "loops", "title": ""}'
|
||||||
|
|
||||||
|
We can also use the type hierarchy as starting point of our
|
||||||
|
journey.
|
||||||
|
|
||||||
|
>>> node_types = addAndConfigureObject(apiRoot, ApiNode, 'types')
|
||||||
|
>>> node_types.target = type_type
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'types')
|
||||||
|
'[{"name": "topic", "title": ""}, ... {"name": "type", "title": "Type"}]'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'types/topic')
|
||||||
|
'[{"name": "loops", "title": ""}]'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'types/topic/loops')
|
||||||
|
'{"name": "loops", "title": ""}'
|
||||||
|
|
||||||
|
Next steps
|
||||||
|
- return properties of target object as given by interface/schema
|
||||||
|
- traverse special attributes/methods (e.g. _children) of target topic
|
||||||
|
|
||||||
|
Creating new objects with POST
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
>>> input = '{"name": "rdf", "title": "RDF"}'
|
||||||
|
>>> callPath(apiRoot, 'types/topic', 'POST', input=input)
|
||||||
|
INFO: Input Data: {'name': 'rdf', 'title': 'RDF'}
|
||||||
|
'{"info": "Done"}'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'types/topic')
|
||||||
|
'[{"name": "loops", "title": ""}, {"name": "rdf", "title": "RDF"}]'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'types/topic/rdf')
|
||||||
|
'{"name": "rdf", "title": "RDF"}'
|
||||||
|
|
||||||
|
>>> input = '{"name": "task0001", "title": "Document loops WebAPI"}'
|
||||||
|
>>> callPath(apiRoot, 'types/task', 'POST', input=input)
|
||||||
|
INFO: Input Data: {'name': 'task0001', 'title': 'Document loops WebAPI'}
|
||||||
|
'{"info": "Done"}'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'types/task')
|
||||||
|
'[{"name": "task0001", "title": "Document loops WebAPI"}]'
|
||||||
|
|
||||||
|
Updating objects with PUT
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
>>> input = '{"title": "loops"}'
|
||||||
|
>>> callPath(apiRoot, 'topics/loops', 'PUT', input=input)
|
||||||
|
INFO: Input Data: {'title': 'loops'}
|
||||||
|
'{"info": "Done"}'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'topics')
|
||||||
|
'[{"name": "loops", "title": "loops"}, {"name": "rdf", "title": "RDF"}]'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'topics/loops')
|
||||||
|
'{"name": "loops", "title": "loops"}'
|
||||||
|
|
||||||
|
Let's just see what happens if we do not supply input data.
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'topics/loops', 'PUT', input='{}')
|
||||||
|
INFO: Input Data: {}
|
||||||
|
ERROR: Missing data
|
||||||
|
'{"message": "Missing data", "status": 500}'
|
||||||
|
|
||||||
|
Create relationships (links) between objects - assign a child.
|
||||||
|
|
||||||
|
... TODO ...
|
||||||
|
|
||||||
|
Client module
|
||||||
|
=============
|
||||||
|
|
||||||
|
>>> from cco.webapi.client import postMessage
|
||||||
|
|
||||||
|
>>> postMessage('test://localhost:8123/webapi',
|
||||||
|
... 'demo', 'query', 'topics', 'rdf')
|
||||||
|
request: POST test://localhost:8123/webapi/demo/query/topics/rdf
|
||||||
|
None
|
||||||
|
auth: None
|
||||||
|
'{"state": "success"}'
|
||||||
|
|
||||||
|
Asynchronous processing of integrator messages
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
query action
|
||||||
|
------------
|
||||||
|
|
||||||
|
>>> node_domain = addAndConfigureObject(apiRoot, ApiNode, 'demo')
|
||||||
|
>>> node_query = addAndConfigureObject(node_domain, ApiNode, 'query')
|
||||||
|
>>> node_query.target = type_type
|
||||||
|
>>> node_query.viewName = 'api_integrator_query'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'demo/query/topic')
|
||||||
|
request: POST test://localhost:8123/webapi/demo/list/topic
|
||||||
|
{"_item": "loops", "title": "loops"}...
|
||||||
|
auth: None
|
||||||
|
'"{\\"state\\": \\"success\\"}"'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'demo/query/topic/loops')
|
||||||
|
request: POST test://localhost:8123/webapi/demo/data/topic/loops
|
||||||
|
{"title": "loops"}
|
||||||
|
auth: None
|
||||||
|
'"{\\"state\\": \\"success\\"}"'
|
||||||
|
|
||||||
|
data action
|
||||||
|
-----------
|
||||||
|
|
||||||
|
>>> node_data = addAndConfigureObject(node_domain, ApiNode, 'data')
|
||||||
|
>>> node_data.target = type_type
|
||||||
|
|
||||||
|
>>> input = '{"name": "typescript", "title": "Typescript"}'
|
||||||
|
>>> callPath(apiRoot, 'demo/data/topic', 'POST', input=input)
|
||||||
|
INFO: Input Data: {'name': 'typescript', 'title': 'Typescript'}
|
||||||
|
'{"info": "Done"}'
|
||||||
|
|
||||||
|
>>> input = '{"title": "TypeScript"}'
|
||||||
|
>>> callPath(apiRoot, 'demo/data/topic/typescript', 'POST', input=input)
|
||||||
|
INFO: Input Data: {'title': 'TypeScript'}
|
||||||
|
'{"info": "Done"}'
|
||||||
|
|
||||||
|
>>> input = '{"title": "TypeScript"}'
|
||||||
|
>>> callPath(apiRoot, 'demo/data/topic/typescript', 'POST', input=input)
|
||||||
|
INFO: Input Data: {'title': 'TypeScript'}
|
||||||
|
'{"info": "Done"}'
|
||||||
|
|
||||||
|
>>> callPath(apiRoot, 'demo/query/topic')
|
||||||
|
request: POST test://localhost:8123/webapi/demo/list/topic
|
||||||
|
{"_item": "loops", "title": "loops"}...
|
||||||
|
auth: None
|
||||||
|
'"{\\"state\\": \\"success\\"}"'
|
||||||
|
|
||||||
|
|
8
cco/webapi/__init__.py
Normal file
8
cco/webapi/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
""" package cco.webapi
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cybertools.util.jeep import Jeep
|
||||||
|
|
||||||
|
# override in your application package:
|
||||||
|
config = Jeep(integrator=dict(url='http://localhost:8123/webapi'))
|
||||||
|
|
1
cco/webapi/browser/__init__.py
Normal file
1
cco/webapi/browser/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# cco.webapi.browser
|
73
cco/webapi/browser/add.pt
Normal file
73
cco/webapi/browser/add.pt
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<html metal:use-macro="context/@@standard_macros/page"
|
||||||
|
i18n:domain="zope">
|
||||||
|
<body>
|
||||||
|
<div metal:fill-slot="body">
|
||||||
|
|
||||||
|
<div metal:define-macro="addform">
|
||||||
|
|
||||||
|
<form action="." tal:attributes="action request/URL"
|
||||||
|
method="post" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<div metal:define-macro="formbody">
|
||||||
|
|
||||||
|
<h3 tal:condition="view/label"
|
||||||
|
tal:content="view/label"
|
||||||
|
metal:define-slot="heading"
|
||||||
|
i18n:translate=""
|
||||||
|
>Edit something</h3>
|
||||||
|
|
||||||
|
<p tal:define="status view/update"
|
||||||
|
tal:condition="status"
|
||||||
|
tal:content="status"
|
||||||
|
i18n:translate=""/>
|
||||||
|
|
||||||
|
<p tal:condition="view/errors" i18n:translate="">
|
||||||
|
There are <strong tal:content="python:len(view.errors)"
|
||||||
|
i18n:name="num_errors">6</strong> input errors.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div metal:define-slot="extra_info" tal:replace="nothing">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" metal:define-slot="extra_top" tal:replace="nothing">
|
||||||
|
<div class="label">Extra top</div>
|
||||||
|
<div class="label"><input type="text" style="width:100%" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div metal:use-macro="context/@@form_macros/widget_rows" />
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
<div class="row"
|
||||||
|
metal:define-slot="extra_bottom" tal:replace="nothing">
|
||||||
|
<div class="label">Extra bottom</div>
|
||||||
|
<div class="field"><input type="text" style="width:100%" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<br/><br/>
|
||||||
|
<div class="row">
|
||||||
|
<div class="controls"><hr />
|
||||||
|
<span tal:condition="context/nameAllowed|nothing" tal:omit-tag="">
|
||||||
|
<b i18n:translate="">Object Name</b>
|
||||||
|
<input type='text' name='add_input_name'
|
||||||
|
tal:attributes="value context/contentName" />
|
||||||
|
</span>
|
||||||
|
<input type='submit' value='Add' name='UPDATE_SUBMIT'
|
||||||
|
i18n:attributes='value add-button' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" metal:define-slot="extra_buttons" tal:replace="nothing">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
90
cco/webapi/browser/edit.pt
Normal file
90
cco/webapi/browser/edit.pt
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
<tal:tag condition="view/update" />
|
||||||
|
<html metal:use-macro="context/@@standard_macros/view"
|
||||||
|
i18n:domain="zope">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<metal:js fill-slot="ecmascript_slot">
|
||||||
|
<script type="text/javascript"
|
||||||
|
src="loops.js"
|
||||||
|
tal:attributes="src string:${context/++resource++loops.js}">
|
||||||
|
</script>
|
||||||
|
<metal:use use-macro="views/ajax.dojo/main"
|
||||||
|
tal:condition="nothing" />
|
||||||
|
</metal:js>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div metal:fill-slot="body">
|
||||||
|
<div metal:define-macro="body">
|
||||||
|
|
||||||
|
<form action="." tal:attributes="action request/URL" method="post"
|
||||||
|
enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<input type="hidden" name="form_submitted" value="true" />
|
||||||
|
<tal:control condition="nothing">
|
||||||
|
<script language="JavaScript">
|
||||||
|
focusOpener();
|
||||||
|
</script>
|
||||||
|
</tal:control>
|
||||||
|
|
||||||
|
<div metal:define-macro="formbody">
|
||||||
|
|
||||||
|
<h3 tal:condition="view/label"
|
||||||
|
metal:define-slot="heading">
|
||||||
|
<span tal:content="view/label"
|
||||||
|
i18n:translate="">Edit something</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p tal:define="status view/update"
|
||||||
|
tal:condition="status"
|
||||||
|
tal:content="status"
|
||||||
|
i18n:translate=""/>
|
||||||
|
|
||||||
|
<p tal:condition="view/errors" i18n:translate="">
|
||||||
|
There are <strong tal:content="python:len(view.errors)"
|
||||||
|
i18n:name="num_errors">6</strong> input errors.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div metal:define-slot="extra_info" tal:replace="nothing">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row"
|
||||||
|
metal:define-slot="extra_top" tal:replace="nothing">
|
||||||
|
<div class="label">Extra top</div>
|
||||||
|
<div class="field"><input type="text" style="width:100%" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div metal:use-macro="context/@@form_macros/widget_rows" />
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
<div class="row"
|
||||||
|
metal:define-slot="extra_bottom" tal:replace="nothing">
|
||||||
|
<div class="label">Extra bottom</div>
|
||||||
|
<div class="field"><input type="text" style="width:100%" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="separator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div metal:define-macro="submit_button"
|
||||||
|
class="row">
|
||||||
|
<div class="controls">
|
||||||
|
<input type="submit" name="UPDATE_SUBMIT" value="Change"
|
||||||
|
i18n:attributes="value submit-button;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" metal:define-slot="extra_buttons" tal:replace="nothing">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form metal:use-macro="views/resource_macros/delete_object" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
69
cco/webapi/client.py
Normal file
69
cco/webapi/client.py
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
#
|
||||||
|
# cco.webapi.client
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Functions for providieng external services with object data
|
||||||
|
via a REST-JSON API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from logging import getLogger
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from cco.processor import hook
|
||||||
|
from cco.webapi import testing
|
||||||
|
|
||||||
|
logger = getLogger('cco.webapi.client')
|
||||||
|
|
||||||
|
|
||||||
|
def sendJson(url, payload, cred, method):
|
||||||
|
if url.startswith('test:'):
|
||||||
|
resp = testing.request(method, url, json=payload, auth=cred)
|
||||||
|
else:
|
||||||
|
if isinstance(payload, basestring):
|
||||||
|
resp = requests.request(
|
||||||
|
method, url, data=payload, auth=cred, timeout=10)
|
||||||
|
else:
|
||||||
|
resp = requests.request(
|
||||||
|
method, url, json=payload, auth=cred, timeout=10)
|
||||||
|
logger.info('sendJson: %s %s -> %s %s.' % (
|
||||||
|
method, url, resp.status_code, resp.text))
|
||||||
|
# TODO: check resp.status_code
|
||||||
|
#return resp.json(), dict(state='success')
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
def postJson(url, payload, cred):
|
||||||
|
return sendJson(url, payload, cred, 'POST')
|
||||||
|
|
||||||
|
|
||||||
|
def postMessage(baseUrl, domain='system', action='data', class_='', item='',
|
||||||
|
payload=None, cred=None):
|
||||||
|
url = '/'.join(p for p in (baseUrl, domain, action, class_, item) if p)
|
||||||
|
return postJson(url, payload, cred)
|
||||||
|
|
||||||
|
|
||||||
|
def postStandardMessage(action='data', class_="", item='', payload=None):
|
||||||
|
from cco.webapi import config
|
||||||
|
baseUrl = config.integrator.get('url') or 'http://localhost:8123'
|
||||||
|
domain = config.integrator.get('domain') or 'demo'
|
||||||
|
cred = config.integrator.get('cred')
|
||||||
|
return postMessage(baseUrl, domain, action, class_, item, payload, cred)
|
||||||
|
|
||||||
|
|
||||||
|
def notify(obj, data):
|
||||||
|
name = 'notifier'
|
||||||
|
config = obj._hook_config.get(name)
|
||||||
|
if config is None:
|
||||||
|
logger.warn('config missing: %s' %
|
||||||
|
dict(hook=name, obj=obj))
|
||||||
|
return
|
||||||
|
baseUrl = config.get('url', 'http://localhost:8123')
|
||||||
|
cred = config.get('_credentials', ('dummy', 'dummy'))
|
||||||
|
url = '/'.join((baseUrl, obj._hook_message_base, obj.identifier))
|
||||||
|
logger.info('notify: %s - %s - %s.' % (url, data, cred))
|
||||||
|
postJson(url, data, cred)
|
||||||
|
|
||||||
|
|
||||||
|
hook.processor_hooks['notifier'] = notify
|
151
cco/webapi/configure.zcml
Normal file
151
cco/webapi/configure.zcml
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
<configure
|
||||||
|
xmlns:zope="http://namespaces.zope.org/zope"
|
||||||
|
xmlns:browser="http://namespaces.zope.org/browser"
|
||||||
|
i18n_domain="cco.webapi">
|
||||||
|
|
||||||
|
<!-- security -->
|
||||||
|
|
||||||
|
<zope:permission
|
||||||
|
id="cco.webapi.Get"
|
||||||
|
title="[cco-webapi-get-permission] cco.webapi: GET" />
|
||||||
|
|
||||||
|
<zope:permission
|
||||||
|
id="cco.webapi.Post"
|
||||||
|
title="[cco-webapi-post-permission] cco.webapi: POST" />
|
||||||
|
|
||||||
|
<zope:permission
|
||||||
|
id="cco.webapi.Put"
|
||||||
|
title="[cco-webapi-put-permission] cco.webapi: PUT" />
|
||||||
|
|
||||||
|
<zope:role id="cco.webapi.All"
|
||||||
|
title="[cco-webapi-all-role] cco.webapi: All" />
|
||||||
|
<zope:grant role="cco.webapi.All" permission="cco.webapi.Get" />
|
||||||
|
<zope:grant role="cco.webapi.All" permission="cco.webapi.Post" />
|
||||||
|
<zope:grant role="cco.webapi.All" permission="cco.webapi.Put" />
|
||||||
|
|
||||||
|
<!-- node classes -->
|
||||||
|
|
||||||
|
<zope:class class="cco.webapi.node.ApiNode">
|
||||||
|
<implements interface="zope.annotation.interfaces.IAttributeAnnotatable" />
|
||||||
|
<factory id="cco.webapi.ApiNode" description="API Node" />
|
||||||
|
<require
|
||||||
|
permission="zope.View"
|
||||||
|
interface="cco.webapi.interfaces.IApiNode" />
|
||||||
|
<require
|
||||||
|
permission="zope.ManageContent"
|
||||||
|
set_schema="cco.webapi.interfaces.IApiNode" />
|
||||||
|
</zope:class>
|
||||||
|
|
||||||
|
<!-- admin views -->
|
||||||
|
|
||||||
|
<browser:addform
|
||||||
|
label="Add API Node"
|
||||||
|
name="AddCcoApiNode.html"
|
||||||
|
content_factory="cco.webapi.node.ApiNode"
|
||||||
|
schema="cco.webapi.interfaces.IApiNode"
|
||||||
|
fields="title description nodeType viewName"
|
||||||
|
template="browser/add.pt"
|
||||||
|
permission="zope.ManageContent">
|
||||||
|
<widget field="description" height="2" />
|
||||||
|
<widget field="body" height="3" />
|
||||||
|
</browser:addform>
|
||||||
|
|
||||||
|
<browser:addMenuItem
|
||||||
|
class="cco.webapi.node.ApiNode"
|
||||||
|
title="API Node"
|
||||||
|
description="An API node allows access via REST+JSON"
|
||||||
|
permission="zope.ManageContent"
|
||||||
|
view="AddCcoApiNode.html" />
|
||||||
|
|
||||||
|
<browser:editform
|
||||||
|
label="Edit API Node"
|
||||||
|
name="edit.html"
|
||||||
|
schema="cco.webapi.interfaces.IApiNode"
|
||||||
|
fields="title description nodeType viewName"
|
||||||
|
for="cco.webapi.interfaces.IApiNode"
|
||||||
|
template="browser/edit.pt"
|
||||||
|
permission="zope.ManageContent">
|
||||||
|
<widget field="description" height="2" />
|
||||||
|
<widget field="body" height="3" />
|
||||||
|
</browser:editform>
|
||||||
|
|
||||||
|
<!-- API traverser and views/handlers -->
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
for="cco.webapi.interfaces.IApiNode
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.publisher.interfaces.browser.IBrowserPublisher"
|
||||||
|
factory="cco.webapi.server.ApiTraverser"
|
||||||
|
permission="zope.Public" />
|
||||||
|
|
||||||
|
<browser:page
|
||||||
|
name="index.html"
|
||||||
|
for="cco.webapi.interfaces.IApiNode"
|
||||||
|
class="cco.webapi.server.ApiHandler"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="PUT"
|
||||||
|
for="cco.webapi.interfaces.IApiNode
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.ApiHandler"
|
||||||
|
permission="cco.webapi.Put" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_target"
|
||||||
|
for="loops.interfaces.IConceptSchema
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.TargetHandler"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_container"
|
||||||
|
for="loops.interfaces.IConceptSchema
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.ContainerHandler"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_target"
|
||||||
|
for="loops.interfaces.ITypeConcept
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.TypeHandler"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_container"
|
||||||
|
for="loops.interfaces.ITypeConcept
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.TypeHandler"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_integrator_query"
|
||||||
|
for="loops.interfaces.ITypeConcept
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.IntegratorQuery"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_integrator_class_query"
|
||||||
|
for="loops.interfaces.ITypeConcept
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.IntegratorClassQuery"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="api_integrator_item_query"
|
||||||
|
for="loops.interfaces.IConceptSchema
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.webapi.server.IntegratorItemQuery"
|
||||||
|
permission="cco.webapi.Get" />
|
||||||
|
|
||||||
|
</configure>
|
29
cco/webapi/interfaces.py
Normal file
29
cco/webapi/interfaces.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
API node interfaces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from zope.app.container.constraints import contains, containers
|
||||||
|
from zope.interface import Interface
|
||||||
|
from zope import schema
|
||||||
|
|
||||||
|
from loops.interfaces import INodeSchema, INode, IViewManager
|
||||||
|
|
||||||
|
|
||||||
|
class IApiBase(INodeSchema):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IApiNode(IApiBase, INode):
|
||||||
|
|
||||||
|
contains(IApiBase)
|
||||||
|
|
||||||
|
|
||||||
|
class IApiNodeContained(Interface):
|
||||||
|
|
||||||
|
containers(IApiNode, IViewManager)
|
||||||
|
|
15
cco/webapi/node.py
Normal file
15
cco/webapi/node.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# cco.webapi.node
|
||||||
|
|
||||||
|
""" API node implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
from cco.webapi.interfaces import IApiNode, IApiNodeContained
|
||||||
|
from loops.view import Node
|
||||||
|
|
||||||
|
|
||||||
|
@implementer(IApiNode, IApiNodeContained)
|
||||||
|
class ApiNode(Node):
|
||||||
|
|
||||||
|
pass
|
341
cco/webapi/server.py
Normal file
341
cco/webapi/server.py
Normal file
|
@ -0,0 +1,341 @@
|
||||||
|
#
|
||||||
|
# Copyright (c) 2017 Helmut Merz helmutm@cy55.de
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
View-like implementations for the REST API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
from json import dumps, loads, JSONEncoder
|
||||||
|
from zope.app.container.traversal import ItemTraverser
|
||||||
|
from zope.cachedescriptors.property import Lazy
|
||||||
|
from zope import component
|
||||||
|
from zope.traversing.api import getName, getParent
|
||||||
|
|
||||||
|
from cybertools.util import format
|
||||||
|
from loops.browser.concept import ConceptView
|
||||||
|
from loops.browser.node import NodeView
|
||||||
|
from loops.common import adapted, baseObject
|
||||||
|
from loops.concept import Concept
|
||||||
|
from loops.setup import addAndConfigureObject
|
||||||
|
from cco.webapi.client import postStandardMessage
|
||||||
|
|
||||||
|
# provide lower-level (RDF-like?) API for accessing the concept map
|
||||||
|
# in a simple and generic way.
|
||||||
|
# next steps: RDF-like API for resources and tracks
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('cco.webapi.server')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
#logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
class Encoder(JSONEncoder):
|
||||||
|
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, date):
|
||||||
|
return format.formatDate(obj)
|
||||||
|
try:
|
||||||
|
return JSONEncoder.default(self, obj)
|
||||||
|
except TypeError:
|
||||||
|
id = getattr(obj, 'identifier', None)
|
||||||
|
if id is None:
|
||||||
|
return repr(obj)
|
||||||
|
else:
|
||||||
|
return id
|
||||||
|
|
||||||
|
def dumps(obj):
|
||||||
|
return Encoder().encode(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCommon(object):
|
||||||
|
""" Common routines for logging, error handling, etc
|
||||||
|
"""
|
||||||
|
|
||||||
|
# HTTP Status: see https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
|
||||||
|
|
||||||
|
logger = logger
|
||||||
|
|
||||||
|
def logInfo(self, message=None):
|
||||||
|
self.logger.info(message)
|
||||||
|
|
||||||
|
def logDebug(self, message=None):
|
||||||
|
self.logger.debug(message)
|
||||||
|
|
||||||
|
def success(self, message='Done', **kw):
|
||||||
|
if isinstance(message, dict):
|
||||||
|
kw.update(message)
|
||||||
|
else:
|
||||||
|
kw.update(dict(info=message))
|
||||||
|
self.logger.debug(message)
|
||||||
|
return dumps(kw)
|
||||||
|
|
||||||
|
def error(self, message, status=500):
|
||||||
|
self.logger.error(message)
|
||||||
|
self.request.response.setStatus(status, message)
|
||||||
|
return dumps(dict(message=message, status=status))
|
||||||
|
|
||||||
|
|
||||||
|
class ApiHandler(ApiCommon, NodeView):
|
||||||
|
|
||||||
|
def __call__(self, *args, **kw):
|
||||||
|
self.logDebug('Request Env: ' + str(self.request._environ))
|
||||||
|
self.request.response.setHeader('content-type', 'application/json')
|
||||||
|
targetView = self.viewAnnotations.get('targetView')
|
||||||
|
if targetView is not None:
|
||||||
|
return targetView()
|
||||||
|
target = self.context.target
|
||||||
|
if target is not None:
|
||||||
|
targetView = self.getContainerView(target)
|
||||||
|
return targetView()
|
||||||
|
# TODO: check for request.method?
|
||||||
|
if self.request.method in ('PUT', 'POST'):
|
||||||
|
return self.error('Method not allowed', 405)
|
||||||
|
return dumps(self.getData())
|
||||||
|
|
||||||
|
PUT = __call__
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
return [dict(name=getName(n)) for n in self.context.values()]
|
||||||
|
|
||||||
|
def get(self, name):
|
||||||
|
#self.logInfo('*** NodeView: traversing ' + name)
|
||||||
|
targetView = self.viewAnnotations.get('targetView')
|
||||||
|
if targetView is not None:
|
||||||
|
#cv = self.getContainerView(targetView.adapted)
|
||||||
|
#if cv is None:
|
||||||
|
targetView = targetView.getView(name)
|
||||||
|
#else:
|
||||||
|
# targetView = cv.getView(name)
|
||||||
|
else:
|
||||||
|
target = self.context.target
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
cv = self.getContainerView(target)
|
||||||
|
targetView = cv.getView(name)
|
||||||
|
if targetView is None:
|
||||||
|
return None
|
||||||
|
self.viewAnnotations['targetView'] = targetView
|
||||||
|
return self.context
|
||||||
|
|
||||||
|
def getContainerView(self, target):
|
||||||
|
viewName = self.context.viewName or 'api_container'
|
||||||
|
return component.queryMultiAdapter(
|
||||||
|
(adapted(target), self.request), name=viewName)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTraverser(ItemTraverser):
|
||||||
|
|
||||||
|
def publishTraverse(self, request, name):
|
||||||
|
if self.context.get(name) is None:
|
||||||
|
obj = ApiHandler(self.context, request).get(name)
|
||||||
|
if obj is not None:
|
||||||
|
return obj
|
||||||
|
return self.defaultTraverse(request, name)
|
||||||
|
|
||||||
|
def defaultTraverse(self, request, name):
|
||||||
|
return super(ApiTraverser, self).publishTraverse(request, name)
|
||||||
|
|
||||||
|
|
||||||
|
# target / concept views
|
||||||
|
|
||||||
|
class TargetBase(ApiCommon, ConceptView):
|
||||||
|
|
||||||
|
routes = {}
|
||||||
|
|
||||||
|
inputFieldMap = {}
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def outputFieldMap(self):
|
||||||
|
return dict((v, k) for k, v in self.inputFieldMap.items())
|
||||||
|
|
||||||
|
def __call__(self, *args, **kw):
|
||||||
|
if self.request.method == 'POST':
|
||||||
|
return self.create()
|
||||||
|
if self.request.method == 'PUT':
|
||||||
|
return self.update()
|
||||||
|
return dumps(self.getData())
|
||||||
|
|
||||||
|
def getName(self, obj):
|
||||||
|
obj = baseObject(obj)
|
||||||
|
name = getName(obj)
|
||||||
|
prefix = adapted(obj.getType()).namePrefix
|
||||||
|
if prefix and name.startswith(prefix):
|
||||||
|
name = name[len(prefix):]
|
||||||
|
return name
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
# error
|
||||||
|
return self.error('Not allowed', 405)
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
data = self.getInputData()
|
||||||
|
if not data:
|
||||||
|
return self.error('Missing data')
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(self.adapted, k, v)
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
def getInputData(self):
|
||||||
|
data = self.getPostData()
|
||||||
|
if data:
|
||||||
|
self.mapInputFieldNames(data)
|
||||||
|
self.unmarshalValues(data)
|
||||||
|
self.logInfo('Input Data: ' + repr(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
def getPostData(self):
|
||||||
|
stream = self.request.bodyStream
|
||||||
|
if stream is not None:
|
||||||
|
#json = stream.read(None)
|
||||||
|
json = stream.read(-1)
|
||||||
|
self.logDebug('POST Data: ' + repr(json))
|
||||||
|
if json:
|
||||||
|
return loads(json)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def mapInputFieldNames(self, data):
|
||||||
|
for k1 in data:
|
||||||
|
k2 = self.inputFieldMap.get(k1)
|
||||||
|
if k2 is not None:
|
||||||
|
data[k2] = data[k1]
|
||||||
|
del data[k1]
|
||||||
|
|
||||||
|
def mapOutputFieldNames(self, data):
|
||||||
|
for k1 in data:
|
||||||
|
k2 = self.outputFieldMap.get(k1)
|
||||||
|
if k2 is not None:
|
||||||
|
data[k2] = data[k1]
|
||||||
|
del data[k1]
|
||||||
|
|
||||||
|
def unmarshalValues(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def marshalValues(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TargetHandler(TargetBase):
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
return self.update()
|
||||||
|
|
||||||
|
def getView(self, name):
|
||||||
|
cname = self.routes.get(name)
|
||||||
|
if cname is not None:
|
||||||
|
name = cname
|
||||||
|
view = component.getMultiAdapter(
|
||||||
|
(self.adapted, self.request), name=name)
|
||||||
|
return view
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
obj = self.context
|
||||||
|
# TODO: use schema (self.adapted and typeInterface) to get all properties
|
||||||
|
data = dict(name=self.getName(obj), title=obj.title)
|
||||||
|
self.marshalValues(data)
|
||||||
|
self.mapOutputFieldNames(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerHandler(TargetBase):
|
||||||
|
|
||||||
|
itemViewName = 'api_target'
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
# TODO: check for real listing method and parameters
|
||||||
|
# (or produce list in the caller and use it directly as context)
|
||||||
|
lst = self.context.getChildren()
|
||||||
|
return [dict(name=self.getName(obj), title=obj.title) for obj in lst]
|
||||||
|
|
||||||
|
def getView(self, name):
|
||||||
|
#print '*** ContainerHandler: traversing', name, self
|
||||||
|
# TODO: check for special attributes
|
||||||
|
# TODO: retrieve object from list of children
|
||||||
|
obj = self.getObject(name)
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
view = component.getMultiAdapter(
|
||||||
|
(adapted(obj), self.request), name=self.itemViewName)
|
||||||
|
return view
|
||||||
|
|
||||||
|
def getObject(self, name):
|
||||||
|
self.error('getObject: To be implemented by subclass')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def createObject(self, tp, data=None):
|
||||||
|
if data is None:
|
||||||
|
data = self.getInputData()
|
||||||
|
if not data:
|
||||||
|
self.error('Missing data')
|
||||||
|
return None
|
||||||
|
cname = tp.conceptManager or 'concepts'
|
||||||
|
container = self.loopsRoot[cname]
|
||||||
|
prefix = tp.namePrefix or ''
|
||||||
|
name = data.get('name')
|
||||||
|
if name is None:
|
||||||
|
name = self.generateName(data)
|
||||||
|
obj = addAndConfigureObject(container, Concept, prefix + name,
|
||||||
|
title=data.get('title') or '',
|
||||||
|
conceptType=baseObject(tp))
|
||||||
|
return adapted(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class TypeHandler(ContainerHandler):
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
lst = self.adapted.typeInstances
|
||||||
|
return [dict(name=self.getName(obj), title=obj.title) for obj in lst]
|
||||||
|
|
||||||
|
def getObject(self, name):
|
||||||
|
# TODO: use catalog query
|
||||||
|
tp = self.adapted
|
||||||
|
cname = tp.conceptManager or 'concepts'
|
||||||
|
prefix = tp.namePrefix or ''
|
||||||
|
return self.loopsRoot[cname].get(prefix + name)
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
obj = self.createObject(self.adapted)
|
||||||
|
return self.success()
|
||||||
|
|
||||||
|
|
||||||
|
class IntegratorQuery(TypeHandler):
|
||||||
|
|
||||||
|
itemViewName = 'api_integrator_class_query'
|
||||||
|
|
||||||
|
|
||||||
|
class IntegratorClassQuery(TypeHandler):
|
||||||
|
|
||||||
|
itemViewName = 'api_integrator_item_query'
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
class_ = self.getName(self.context)
|
||||||
|
lst = self.adapted.typeInstances
|
||||||
|
data = [dumps(dict(_item=self.getName(obj), title=obj.title))
|
||||||
|
for obj in lst]
|
||||||
|
return postStandardMessage('list', class_, payload='\n'.join(data))
|
||||||
|
|
||||||
|
|
||||||
|
class IntegratorItemQuery(TargetHandler):
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
class_ = self.getName(self.context.getType())
|
||||||
|
item = self.getName(self.context)
|
||||||
|
data = dict(title=self.context.title)
|
||||||
|
return postStandardMessage('data', class_, item, payload=dumps(data))
|
||||||
|
|
21
cco/webapi/testing.py
Normal file
21
cco/webapi/testing.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
#
|
||||||
|
# cco.webapi.testing
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Mock classes and functions for testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Response(object):
|
||||||
|
|
||||||
|
def __init__(self, status, content, text):
|
||||||
|
self.status_code = status
|
||||||
|
self.content = content
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
|
||||||
|
def request(method, url, json, auth):
|
||||||
|
print('request: %s %s\n%s\nauth: %s' % (method, url, json, auth))
|
||||||
|
result = '{"state": "success"}'
|
||||||
|
return Response(200, result, result)
|
||||||
|
|
100
cco/webapi/tests.py
Normal file
100
cco/webapi/tests.py
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
# cco.webapi.tests
|
||||||
|
|
||||||
|
""" Tests for the 'cco.webapi' package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest, doctest
|
||||||
|
from zope.app.testing.setup import placefulSetUp, placefulTearDown
|
||||||
|
from zope import component
|
||||||
|
from zope.interface import Interface
|
||||||
|
from zope.publisher.browser import TestRequest
|
||||||
|
from zope.publisher.interfaces.browser import IBrowserRequest
|
||||||
|
|
||||||
|
from loops.interfaces import IConceptSchema, ITypeConcept
|
||||||
|
from loops.setup import importData as baseImportData
|
||||||
|
from loops.tests.setup import TestSite
|
||||||
|
|
||||||
|
from cco.webapi.server import ApiHandler, ApiTraverser
|
||||||
|
from cco.webapi.server import TargetHandler, ContainerHandler, TypeHandler
|
||||||
|
from cco.webapi.server import IntegratorQuery, IntegratorClassQuery, IntegratorItemQuery
|
||||||
|
|
||||||
|
import cco.webapi
|
||||||
|
cco.webapi.config.integrator['url'] = 'test://localhost:8123/webapi'
|
||||||
|
|
||||||
|
class LogHandler(logging.StreamHandler):
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
print('%s: %s' % (record.levelname, record.msg))
|
||||||
|
|
||||||
|
logger = logging.getLogger('cco.webapi.server')
|
||||||
|
#logger.setLevel(logging.DEBUG)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(LogHandler())
|
||||||
|
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
site = placefulSetUp(True)
|
||||||
|
t = TestSite(site)
|
||||||
|
concepts, resources, views = t.setup()
|
||||||
|
loopsRoot = site['loops']
|
||||||
|
self.globs['loopsRoot'] = loopsRoot
|
||||||
|
component.provideAdapter(TargetHandler,
|
||||||
|
(IConceptSchema, IBrowserRequest), Interface, name='api_target')
|
||||||
|
component.provideAdapter(ContainerHandler,
|
||||||
|
(IConceptSchema, IBrowserRequest), Interface, name='api_container')
|
||||||
|
component.provideAdapter(TypeHandler,
|
||||||
|
(ITypeConcept, IBrowserRequest), Interface, name='api_target')
|
||||||
|
component.provideAdapter(TypeHandler,
|
||||||
|
(ITypeConcept, IBrowserRequest), Interface, name='api_container')
|
||||||
|
component.provideAdapter(IntegratorQuery,
|
||||||
|
(ITypeConcept, IBrowserRequest), Interface, name='api_integrator_query')
|
||||||
|
component.provideAdapter(IntegratorClassQuery,
|
||||||
|
(ITypeConcept, IBrowserRequest), Interface,
|
||||||
|
name='api_integrator_class_query')
|
||||||
|
component.provideAdapter(IntegratorItemQuery,
|
||||||
|
(IConceptSchema, IBrowserRequest), Interface,
|
||||||
|
name='api_integrator_item_query')
|
||||||
|
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
placefulTearDown()
|
||||||
|
|
||||||
|
|
||||||
|
def traverse(root, request, path):
|
||||||
|
obj = root
|
||||||
|
for name in path.split('/'):
|
||||||
|
trav = ApiTraverser(obj, request)
|
||||||
|
obj = trav.publishTraverse(request, name)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def callPath(obj, path='', method='GET', params={}, input=''):
|
||||||
|
env = dict(REQUEST_METHOD=method)
|
||||||
|
request = TestRequest(body_instream=io.BytesIO(input.encode('UTF-8')),
|
||||||
|
environ=env, form=params)
|
||||||
|
if path:
|
||||||
|
obj = traverse(obj, request, path)
|
||||||
|
view = ApiHandler(obj, request)
|
||||||
|
return view()
|
||||||
|
|
||||||
|
|
||||||
|
class Test(unittest.TestCase):
|
||||||
|
"Basic tests."
|
||||||
|
|
||||||
|
def testBasicStuff(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_suite():
|
||||||
|
flags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
|
||||||
|
return unittest.TestSuite((
|
||||||
|
unittest.makeSuite(Test),
|
||||||
|
doctest.DocFileSuite('README.rst', optionflags=flags,
|
||||||
|
setUp=setUp, tearDown=tearDown),
|
||||||
|
))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main(defaultTest='test_suite')
|
Loading…
Add table
Reference in a new issue