# HG changeset patch # User Adrien Di Mascio # Date 1317142031 -7200 # Node ID df15d194a134b736d442e0df107d9b0e2410063e # Parent 65e460690139ace95abdcc0ed57bd50390a4bd21 [views] implement json / jsonp export views (closes #1942658) Json export views are based on the same model as CSV export views. There are two distinct views : - *jsonexport* : direct conversion of the result set into json - *ejsonexport* : convert entities into json The JSONP parameter is named ``callback`` (same as on geonames, dbepdia and a lot of sites) An optional `_indent` request parameter can be passed to pretty print the results. diff -r 65e460690139 -r df15d194a134 dbapi.py --- a/dbapi.py Tue Sep 27 18:46:36 2011 +0200 +++ b/dbapi.py Tue Sep 27 18:47:11 2011 +0200 @@ -223,13 +223,32 @@ return repo_connect(repo, login, cnxprops=cnxprops, **kwargs) def in_memory_repo_cnx(config, login, **kwargs): - """usefull method for testing and scripting to get a dbapi.Connection + """useful method for testing and scripting to get a dbapi.Connection object connected to an in-memory repository instance """ # connection to the CubicWeb repository repo = in_memory_repo(config) return repo, in_memory_cnx(repo, login, **kwargs) + +def anonymous_session(vreg): + """return a new anonymous session + + raises an AuthenticationError if anonymous usage is not allowed + """ + anoninfo = vreg.config.anonymous_user() + if anoninfo is None: # no anonymous user + raise AuthenticationError('anonymous access is not authorized') + anon_login, anon_password = anoninfo + cnxprops = ConnectionProperties(vreg.config.repo_method) + # use vreg's repository cache + repo = vreg.config.repository(vreg) + anon_cnx = repo_connect(repo, anon_login, + cnxprops=cnxprops, password=anon_password) + anon_cnx.vreg = vreg + return DBAPISession(anon_cnx, anon_login) + + class _NeedAuthAccessMock(object): def __getattribute__(self, attr): raise AuthenticationError() diff -r 65e460690139 -r df15d194a134 doc/book/en/devweb/controllers.rst --- a/doc/book/en/devweb/controllers.rst Tue Sep 27 18:46:36 2011 +0200 +++ b/doc/book/en/devweb/controllers.rst Tue Sep 27 18:47:11 2011 +0200 @@ -26,9 +26,23 @@ typically using JSON as a serialization format for input, and sometimes using either JSON or XML for output; +* the JSonpController is a wrapper around the ``ViewController`` that + provides jsonp_ services. Padding can be specified with the + ``callback`` request parameter. Only *jsonexport* / *ejsonexport* + views can be used. If another ``vid`` is specified, it will be + ignored and replaced by *jsonexport*. Request is anonymized + to avoid returning sensitive data and reduce the risks of CSRF attacks; + * the Login/Logout controllers make effective user login or logout requests +.. warning:: + + JsonController will probably be renamed into AjaxController soon since + it has nothing to do with json per se. + +.. _jsonp: http://en.wikipedia.org/wiki/JSONP + `Edition`: * the Edit controller (see :ref:`edit_controller`) handles CRUD diff -r 65e460690139 -r df15d194a134 entity.py --- a/entity.py Tue Sep 27 18:46:36 2011 +0200 +++ b/entity.py Tue Sep 27 18:47:11 2011 +0200 @@ -457,7 +457,7 @@ """custom json dumps hook to dump the entity's eid which is not part of dict structure itself """ - dumpable = dict(self) + dumpable = self.cw_attr_cache.copy() dumpable['eid'] = self.eid return dumpable diff -r 65e460690139 -r df15d194a134 utils.py --- a/utils.py Tue Sep 27 18:46:36 2011 +0200 +++ b/utils.py Tue Sep 27 18:47:11 2011 +0200 @@ -479,10 +479,8 @@ """define a json encoder to be able to encode yams std types""" def default(self, obj): - if hasattr(obj, 'eid'): - d = obj.cw_attr_cache.copy() - d['eid'] = obj.eid - return d + if hasattr(obj, '__json_encode__'): + return obj.__json_encode__() if isinstance(obj, datetime.datetime): return ustrftime(obj, '%Y/%m/%d %H:%M:%S') elif isinstance(obj, datetime.date): @@ -500,8 +498,8 @@ # just return None in those cases. return None - def json_dumps(value): - return json.dumps(value, cls=CubicWebJsonEncoder) + def json_dumps(value, **kwargs): + return json.dumps(value, cls=CubicWebJsonEncoder, **kwargs) class JSString(str): diff -r 65e460690139 -r df15d194a134 web/application.py --- a/web/application.py Tue Sep 27 18:46:36 2011 +0200 +++ b/web/application.py Tue Sep 27 18:47:11 2011 +0200 @@ -23,6 +23,7 @@ import sys from time import clock, time +from contextlib import contextmanager from logilab.common.deprecation import deprecated @@ -32,7 +33,7 @@ from cubicweb import ( ValidationError, Unauthorized, AuthenticationError, NoSelectableObject, BadConnectionId, CW_EVENT_MANAGER) -from cubicweb.dbapi import DBAPISession +from cubicweb.dbapi import DBAPISession, anonymous_session from cubicweb.web import LOGGER, component from cubicweb.web import ( StatusResponse, DirectResponse, Redirect, NotFound, LogOut, @@ -42,6 +43,16 @@ # print information about web session SESSION_MANAGER = None + +@contextmanager +def anonymized_request(req): + orig_session = req.session + req.set_session(anonymous_session(req.vreg)) + try: + yield req + finally: + req.set_session(orig_session) + class AbstractSessionManager(component.Component): """manage session data associated to a session identifier""" __regid__ = 'sessionmanager' diff -r 65e460690139 -r df15d194a134 web/test/unittest_application.py --- a/web/test/unittest_application.py Tue Sep 27 18:46:36 2011 +0200 +++ b/web/test/unittest_application.py Tue Sep 27 18:47:11 2011 +0200 @@ -31,6 +31,7 @@ from cubicweb.devtools.fake import FakeRequest from cubicweb.web import LogOut, Redirect, INTERNAL_FIELD_VALUE from cubicweb.web.views.basecontrollers import ViewController +from cubicweb.web.application import anonymized_request class FakeMapping: """emulates a mapping module""" @@ -424,6 +425,18 @@ self.assertRaises(LogOut, self.app_publish, req, 'logout') self.assertEqual(len(self.open_sessions), 0) + def test_anonymized_request(self): + req = self.request() + self.assertEqual(req.session.login, self.admlogin) + # admin should see anon + admin + self.assertEqual(len(list(req.find_entities('CWUser'))), 2) + with anonymized_request(req): + self.assertEqual(req.session.login, 'anon') + # anon should only see anon user + self.assertEqual(len(list(req.find_entities('CWUser'))), 1) + self.assertEqual(req.session.login, self.admlogin) + self.assertEqual(len(list(req.find_entities('CWUser'))), 2) + def test_non_regr_optional_first_var(self): req = self.request() # expect a rset with None in [0][0] diff -r 65e460690139 -r df15d194a134 web/test/unittest_views_json.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/test/unittest_views_json.py Tue Sep 27 18:47:11 2011 +0200 @@ -0,0 +1,46 @@ +from cubicweb.devtools.testlib import CubicWebTC + +from json import loads + +class JsonViewsTC(CubicWebTC): + + def test_json_rsetexport(self): + req = self.request() + rset = req.execute('Any GN,COUNT(X) GROUPBY GN ORDERBY GN WHERE X in_group G, G name GN') + data = self.view('jsonexport', rset) + self.assertEqual(req.headers_out.getRawHeaders('content-type'), ['application/json']) + self.assertEqual(data, '[["guests", 1], ["managers", 1]]') + + def test_json_rsetexport_with_jsonp(self): + req = self.request() + req.form.update({'callback': 'foo', + 'rql': 'Any GN,COUNT(X) GROUPBY GN ORDERBY GN WHERE X in_group G, G name GN', + }) + data = self.ctrl_publish(req, ctrl='jsonp') + self.assertEqual(req.headers_out.getRawHeaders('content-type'), ['application/javascript']) + # because jsonp anonymizes data, only 'guests' group should be found + self.assertEqual(data, 'foo([["guests", 1]])') + + def test_json_rsetexport_with_jsonp_and_bad_vid(self): + req = self.request() + req.form.update({'callback': 'foo', + 'vid': 'table', # <-- this parameter should be ignored by jsonp controller + 'rql': 'Any GN,COUNT(X) GROUPBY GN ORDERBY GN WHERE X in_group G, G name GN', + }) + data = self.ctrl_publish(req, ctrl='jsonp') + self.assertEqual(req.headers_out.getRawHeaders('content-type'), ['application/javascript']) + # result should be plain json, not the table view + self.assertEqual(data, 'foo([["guests", 1]])') + + def test_json_ersetexport(self): + req = self.request() + rset = req.execute('Any G ORDERBY GN WHERE G is CWGroup, G name GN') + data = loads(self.view('ejsonexport', rset)) + self.assertEqual(req.headers_out.getRawHeaders('content-type'), ['application/json']) + self.assertEqual(data[0]['name'], 'guests') + self.assertEqual(data[1]['name'], 'managers') + + +if __name__ == '__main__': + from logilab.common.testlib import unittest_main + unittest_main() diff -r 65e460690139 -r df15d194a134 web/views/json.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/views/json.py Tue Sep 27 18:47:11 2011 +0200 @@ -0,0 +1,112 @@ +# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb 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 Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with CubicWeb. If not, see . +"""json export views""" + +__docformat__ = "restructuredtext en" +_ = unicode + +from cubicweb.utils import json_dumps +from cubicweb.view import EntityView, AnyRsetView +from cubicweb.web.application import anonymized_request +from cubicweb.web.views import basecontrollers + +class JsonpController(basecontrollers.ViewController): + """The jsonp controller is the same as a ViewController but : + + - anonymize request (avoid CSRF attacks) + - if ``vid`` parameter is passed, make sure it's sensible (i.e. either + "jsonexport" or "ejsonexport") + - if ``callback`` request parameter is passed, it's used as json padding + + + Response's content-type will either be ``application/javascript`` or + ``application/json`` depending on ``callback`` parameter presence or not. + """ + __regid__ = 'jsonp' + + def publish(self, rset=None): + if 'vid' in self._cw.form: + vid = self._cw.form['vid'] + if vid not in ('jsonexport', 'ejsonexport'): + self.warning("vid %s can't be used with jsonp controller, " + "falling back to jsonexport", vid) + self._cw.form['vid'] = 'jsonexport' + else: # if no vid is specified, use jsonexport + self._cw.form['vid'] = 'jsonexport' + with anonymized_request(self._cw): + json_data = super(JsonpController, self).publish(rset) + if 'callback' in self._cw.form: # jsonp + json_padding = self._cw.form['callback'] + # use ``application/javascript`` is ``callback`` parameter is + # provided, let ``application/json`` otherwise + self._cw.set_content_type('application/javascript') + json_data = '%s(%s)' % (json_padding, json_data) + return json_data + + +class JsonMixIn(object): + """mixin class for json views + + Handles the following optional request parameters: + + - ``_indent`` : must be an integer. If found, it is used to pretty print + json output + """ + templatable = False + content_type = 'application/json' + binary = True + + def wdata(self, data): + if '_indent' in self._cw.form: + indent = int(self._cw.form['_indent']) + else: + indent = None + self.w(json_dumps(data, indent=indent)) + + +class JsonRsetView(JsonMixIn, AnyRsetView): + """dumps raw result set in JSON format""" + __regid__ = 'jsonexport' + title = _('json-export-view') + + def call(self): + # XXX mimic w3c recommandations to serialize SPARQL results in json ? + # http://www.w3.org/TR/rdf-sparql-json-res/ + self.wdata(self.cw_rset.rows) + + +class JsonEntityView(JsonMixIn, EntityView): + """dumps rset entities in JSON + + The following additional metadata is added to each row : + + - ``__cwetype__`` : entity type + """ + __regid__ = 'ejsonexport' + title = _('json-entities-export-view') + + def call(self): + entities = [] + for entity in self.cw_rset.entities(): + entity.complete() # fetch all attributes + # hack to add extra metadata + entity.cw_attr_cache.update({ + '__cwetype__': entity.__regid__, + }) + entities.append(entity) + self.wdata(entities)