include cco.processor; cco.webapi: Python3 fixes

This commit is contained in:
Helmut Merz 2024-09-30 11:51:24 +02:00
parent ee29ae7f9e
commit d0dfb4a79c
20 changed files with 1466 additions and 1 deletions

View file

@ -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.

View file

@ -0,0 +1,2 @@
""" package cco.processor
"""

28
cco/processor/common.py Normal file
View 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()

View 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
View 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
View 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
View 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

View 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
View 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
View 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'))

View file

@ -0,0 +1 @@
# cco.webapi.browser

73
cco/webapi/browser/add.pt Normal file
View 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="">
&nbsp;&nbsp;<b i18n:translate="">Object Name</b>&nbsp;&nbsp;
<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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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')