PageRenderTime 25ms CodeModel.GetById 32ms RepoModel.GetById 1ms app.codeStats 0ms

/mgen/web/api.py

https://github.com/stan1y/MrHide
Python | 579 lines | 509 code | 50 blank | 20 comment | 40 complexity | b2072df6b89804c0167bb02557de738d MD5 | raw file
  1. """
  2. MGEN Rest API Inteface classes
  3. """
  4. import zlib
  5. import uuid
  6. import json
  7. import pprint
  8. import functools
  9. import logging
  10. import traceback
  11. import datetime
  12. import enum
  13. import tornado
  14. import tornado.web
  15. import tornado.template
  16. import sqlalchemy
  17. import sqlalchemy.exc
  18. import mgen.util
  19. import mgen.error
  20. import mgen.model
  21. from mgen.model import session, get_primary_key
  22. from mgen.model import Permission
  23. from mgen.web import BaseRequestHandler
  24. from mgen.web.auth import authenticated
  25. log = logging.getLogger(__name__)
  26. def jsonify(method):
  27. """Decorate methods with this to output valid JSON data."""
  28. @functools.wraps(method)
  29. def wrapper(self, *args, **kwargs):
  30. answer = method(self, *args, **kwargs)
  31. if answer:
  32. if self._finished:
  33. log.warn('trying to write JSON on finished request.')
  34. else:
  35. self.set_header('Content-Type', 'application/json')
  36. self.write(json.dumps(answer, cls=mgen.util.JSONEncoder))
  37. return wrapper
  38. class BaseAPIRequestHandler(BaseRequestHandler):
  39. """Base class for API requests processing"""
  40. def write_error(self, status_code, **kwargs):
  41. '''Override default error rendering to report JSON for API calls'''
  42. ex_type, ex, ex_trsbk = kwargs['exc_info']
  43. if not ex or not isinstance(ex, mgen.error.MGENException):
  44. return super().write_error(status_code, **kwargs)
  45. ex.append('traceback', traceback.format_exception(ex_type, ex, ex_trsbk))
  46. self.set_header('Content-Type', 'application/json')
  47. ex_msg = ex.format()
  48. log.error('Exception: %s' % ex_msg)
  49. self.write(ex_msg)
  50. @property
  51. def request_params(self):
  52. '''Read and validate client JSON data for current request'''
  53. if hasattr(self, '__cached_request_params'):
  54. return getattr(self, '__cached_request_params')
  55. try:
  56. content_type = self.request.headers.get('Content-Type')
  57. params = {}
  58. body = self.request.body
  59. log.debug('parsing request params from "%s"' % content_type)
  60. # handle different type of params input
  61. if 'application/json' in content_type:
  62. params = json.loads(body.decode('utf-8'))
  63. if 'application/lzg+json' in content_type:
  64. try:
  65. body = zlib.decompress(body)
  66. except zlib.error as ex:
  67. raise mgen.error.BadRequest().describe("can't decompress body. %s" % ex)
  68. params = json.loads(body.decode('utf-8'))
  69. if 'application/x-www-form-urlencoded' in content_type:
  70. for key in self.request.body_arguments:
  71. params[key] = self.get_body_argument(key)
  72. # cache parsed result
  73. setattr(self, '__cached_request_params', params)
  74. log.debug('-- request params --')
  75. log.debug(pprint.pformat(params, indent=2, width=160))
  76. log.debug('-- end request params --')
  77. return getattr(self, '__cached_request_params')
  78. except ValueError:
  79. raise mgen.error.BadRequest().describe('invalid json received')
  80. def collection_name(model):
  81. return model.__tablename__ + "s"
  82. class GenericModelHandler(BaseAPIRequestHandler):
  83. """Generic REST API handler for models access with paging, sorting, filtering, etc"""
  84. @property
  85. def page_arguments(self):
  86. """Returns tuple of pageing arguments of sent by client"""
  87. #check that all three fields are present
  88. if 'page' in self.request.arguments:
  89. page = int(self.get_argument('page'))
  90. else:
  91. return False, 0, 0, 0
  92. if 'limit' in self.request.arguments:
  93. limit = int(self.get_argument('limit'))
  94. else:
  95. return False, 0, 0, 0
  96. if 'start' in self.request.arguments:
  97. start = int(self.get_argument('start'))
  98. else:
  99. return False, 0, 0, 0
  100. return True, page, start, limit
  101. def sort(self, model, query):
  102. '''Server-side sorting support'''
  103. if not 'sort' in self.request.arguments:
  104. return query
  105. if not issubclass(model, mgen.model.SortModelMixin):
  106. log.warn('not a sorted instance')
  107. return query
  108. return model.sort(query, self.get_argument('sort'))
  109. def filter(self, model, query):
  110. '''Server side filtering support'''
  111. if not 'filter' in self.request.arguments:
  112. return query
  113. if not issubclass(model, mgen.model.FilterModelMixin):
  114. log.warn('not a sorted instance')
  115. return query
  116. return model.filter(query, self.get_argument('filter'))
  117. def range(self, model, query):
  118. '''Server side range query support'''
  119. if not 'range' in self.request.arguments:
  120. return query
  121. if not issubclass(model, mgen.model.RangeModelMixin):
  122. log.warn('not a sorted instance')
  123. return query
  124. return model.range(query, self.get_argument('range'))
  125. def fetch_page(self, query, collection="objects", **kwargs):
  126. """Generic get method for models with paging support and conversion to json"""
  127. should_page, page_no, start, limit = self.page_arguments
  128. if should_page and isinstance(query, sqlalchemy.orm.Query):
  129. query = query.offset(start).limit(limit)
  130. else:
  131. log.debug('fetch list - paging disabled by client')
  132. # perform query and convert results to json
  133. log.debug('--- Begin SQL PAGE query ---')
  134. log.debug(str(query))
  135. log.debug('--- End SQL PAGE query ---')
  136. objects = [obj.to_json() for obj in query]
  137. return {
  138. "page": page_no,
  139. "limit": limit,
  140. "start": start,
  141. "total": len(objects),
  142. collection: objects
  143. }
  144. def get_objects(self, model, query, primary_key = None):
  145. '''Modify base query to get page of objects or single one.'''
  146. cname = collection_name(model)
  147. if primary_key:
  148. pkey = get_primary_key(model)
  149. log.info('select one %s -> %s = "%s"' % (model.__tablename__, pkey, primary_key))
  150. query = query.filter(pkey == primary_key)
  151. log.debug('-- Begin SQL GET query --')
  152. log.debug(str(q))
  153. log.debug('-- End SQL GET query --')
  154. obj = query.first()
  155. if not obj:
  156. raise mgen.error.NotFound().describe('object with %s="%s" was not found in %s' % (
  157. pkey, primary_key, cname))
  158. return {
  159. 'total': 1,
  160. cname: [obj]
  161. }
  162. else:
  163. do_page, page, start, limit = self.page_arguments
  164. qinfo = ''
  165. if 'filter' in self.request.arguments:
  166. flt = self.get_argument('filter')
  167. flt_obj = json.loads(flt)
  168. qinfo += pprint.pformat(flt_obj, indent=2, width=80)
  169. else:
  170. qinfo = 'everything'
  171. if do_page:
  172. qinfo += ' page=%d, start=%d, limit=%d' % (page, start, limit)
  173. log.info('select %s -> %s' % (model.__tablename__, qinfo))
  174. query = self.sort(model, query)
  175. query = self.filter(model, query)
  176. query = self.range(model, query)
  177. return self.fetch_page(query, cname)
  178. def commit_changes(self, s = None):
  179. cls_name = self.__class__.__name__
  180. try:
  181. # use default session if param was omitted
  182. if s is None: s = session()
  183. s.commit()
  184. s.flush()
  185. log.debug("commited changes to session %s" % s)
  186. except sqlalchemy.exc.IntegrityError as ie:
  187. raise mgen.error.Conflict().duplicate_object(
  188. 'Failed to create new object in "{0}". Error: {1}'.format(
  189. cls_name, ie)
  190. )
  191. except Exception as ex:
  192. raise mgen.error.BadRequest().describe(
  193. 'Failed to create new object in "{0}". Error: {1}'.format(
  194. cls_name, ex)
  195. )
  196. def validate_params(self, params_list):
  197. '''Check that given list of param names in present in current request'''
  198. for pname in params_list:
  199. not_none = False
  200. if isinstance(pname, tuple):
  201. pname, not_none = pname
  202. if pname not in self.request_params:
  203. raise mgen.error.BadRequest().describe('missing property "%s"' % pname)
  204. val = self.request_params[pname]
  205. if not_none and (val == None or len(val) == 0):
  206. raise mgen.error.BadRequest().describe('no value given for property "%s"' % pname)
  207. class Profiles(GenericModelHandler):
  208. """Profiles restfull interface"""
  209. @authenticated
  210. @jsonify
  211. def get(self, profile_id=None):
  212. """GET list or single profile"""
  213. q = session().query(Profile)
  214. return self.get_objects(mgen.model.Profile, q, profile_id)
  215. class Projects(GenericModelHandler):
  216. """"Projects restfull interface"""
  217. def query(self):
  218. # select project.title, profile.name, project2profile.permission
  219. # from project join project2profile on project2profile.project = project.project_id
  220. # join profile on profile.email = project2profile.profile
  221. # where profile.email = @profile
  222. return session().query(mgen.model.Project).join(mgen.model.project2profile,
  223. mgen.model.project2profile.c.project==mgen.model.Project.project_id)\
  224. .join(mgen.model.Profile,
  225. mgen.model.Profile.email==mgen.model.project2profile.c.profile)\
  226. .filter(mgen.model.Profile.email==self.current_profile.email)
  227. @authenticated
  228. @jsonify
  229. def get(self, project_id=None):
  230. """GET list or single project"""
  231. log.debug('REST GET %s -> %s' % (self.request.path,
  232. pprint.pformat(self.page_arguments) if project_id is None else '%s=%s' % (
  233. get_primary_key(mgen.model.Project), project_id) ))
  234. return self.get_objects(mgen.model.Project, self.query(), project_id)
  235. @authenticated
  236. @jsonify
  237. def post(self):
  238. log.debug("REST POST %s <- %s" % (self.request.path,
  239. pprint.pformat(self.request_params,
  240. indent=2,
  241. width=160)))
  242. """POST to create a new project"""
  243. self.validate_params([
  244. 'title', 'public_base_uri', 'options'
  245. ])
  246. s = session()
  247. project = mgen.model.Project(project_id=uuid.uuid4(),
  248. title=self.request_params['title'],
  249. public_base_uri=self.request_params['public_base_uri'])
  250. s.add(project)
  251. perm = mgen.model.ProjectPermission.grant(Permission.all(),
  252. project.project_id,
  253. self.current_profile.email)
  254. s.add(perm)
  255. self.commit_changes(s)
  256. self.set_status(201)
  257. log.debug('created new project: %s' % project.project_id)
  258. return self.get_objects(mgen.model.Project,
  259. self.query(),
  260. project.project_id)
  261. class Pages(GenericModelHandler):
  262. def query(self):
  263. return session().query(mgen.model.Page).join(mgen.model.Project,
  264. mgen.model.Project.project_id==mgen.model.Page.project_id)\
  265. .join(mgen.model.project2profile,
  266. mgen.model.project2profile.c.project==mgen.model.Project.project_id)\
  267. .join(mgen.model.Profile,
  268. mgen.model.Profile.email==mgen.model.project2profile.c.profile)\
  269. .filter(mgen.model.Profile.email==self.current_profile.email)
  270. @authenticated
  271. @jsonify
  272. def get(self, page_id=None):
  273. log.debug('REST GET %s -> %s' % (self.request.path,
  274. pprint.pformat(self.page_arguments) if page_id is None else '%s=%s' % (
  275. get_primary_key(mgen.model.Page), page_id) ))
  276. return self.get_objects(mgen.model.Page, self.query(), page_id)
  277. @authenticated
  278. @jsonify
  279. def post(self):
  280. log.debug("REST POST %s <- %s" % (self.request.path,
  281. pprint.pformat(self.request_params,
  282. indent=2,
  283. width=160)))
  284. self.validate_params([
  285. ('path', True),
  286. ('input', True),
  287. 'project_id',
  288. 'template_id'])
  289. proj_id = self.request_params['project_id']
  290. s = session()
  291. project = s.query(mgen.model.Project).filter_by(project_id=proj_id).one()
  292. p = project.get_permission(self.current_profile.email)
  293. if not p & Permission.Edit:
  294. raise mgen.error.Forbidden().describe("You cannot modify project " + proj_id)
  295. p = mgen.model.Page(page_id=uuid.uuid4(),
  296. path=self.request_params['path'],
  297. input=json.loads(self.request_params['input']),
  298. project_id=self.request_params['project_id'],
  299. template_id=self.request_params['template_id'])
  300. s.add(p)
  301. self.commit_changes(s)
  302. self.set_status(201)
  303. log.debug('created new page: %s' % p.page_id)
  304. return self.get_objects(mgen.model.Template,
  305. self.query(),
  306. p.page_id)
  307. class Templates(GenericModelHandler):
  308. """"Templates restfull interface"""
  309. def query(self):
  310. # select template.name, project.title, profile.name, project2profile.permission
  311. # from template join project on project.project_id = template.project_id
  312. # join project2profile on project2profile.project = project.project_id
  313. # join profile on profile.email = project2profile.profile
  314. # where profile.email = @profile
  315. return session().query(mgen.model.Template).join(mgen.model.Project,
  316. mgen.model.Project.project_id==mgen.model.Template.project_id)\
  317. .join(mgen.model.project2profile,
  318. mgen.model.project2profile.c.project==mgen.model.Project.project_id)\
  319. .join(mgen.model.Profile,
  320. mgen.model.Profile.email==mgen.model.project2profile.c.profile)\
  321. .filter(mgen.model.Profile.email==self.current_profile.email)
  322. @authenticated
  323. @jsonify
  324. def get(self, template_id=None):
  325. """GET list or single template"""
  326. log.debug('REST GET %s -> %s' % (self.request.path,
  327. pprint.pformat(self.page_arguments) if template_id is None else '%s=%s' % (
  328. get_primary_key(mgen.model.Template), template_id) ))
  329. return self.get_objects(mgen.model.Template, self.query(), template_id)
  330. @authenticated
  331. @jsonify
  332. def post(self):
  333. log.debug("REST POST %s <- %s" % (self.request.path,
  334. pprint.pformat(self.request_params,
  335. indent=2,
  336. width=160)))
  337. self.validate_params([
  338. ('name', True),
  339. ('type', True),
  340. 'params',
  341. 'data'])
  342. proj_id = self.request_params['project_id']
  343. s = session()
  344. project = s.query(mgen.model.Project).filter_by(project_id=proj_id).one()
  345. p = project.get_permission(self.current_profile.email)
  346. if not p & Permission.Edit:
  347. raise mgen.error.Forbidden().describe("You cannot modify project " + proj_id)
  348. tmpl = mgen.model.Template(template_id=uuid.uuid4(),
  349. name=self.request_params['name'],
  350. type=self.request_params['type'],
  351. project_id=self.request_params['project_id'],
  352. data=self.request_params['data'],
  353. params=self.request_params['params'])
  354. if tmpl.type not in mgen.generator.template.template_types:
  355. raise mgen.error.BadRequest().describe('unsupported template type: %s. supported: %s' % (
  356. tmpl.type, ', '.join(mgen.generator.item_types.keys())))
  357. s.add(tmpl)
  358. self.commit_changes(s)
  359. self.set_status(201)
  360. log.debug('created new template: %s' % tmpl.template_id)
  361. return self.get_objects(mgen.model.Template,
  362. self.query(),
  363. tmpl.template_id)
  364. @authenticated
  365. @jsonify
  366. def put(self, template_id):
  367. self.validate_params([
  368. ('pk', True),
  369. ('name', True),
  370. ('value', True)
  371. ])
  372. s = session()
  373. tmpl = s.query(mgen.model.Template).filter_by(template_id=template_id).one()
  374. p = tmpl.project.get_permission(self.current_profile.email)
  375. if not p & Permission.Edit:
  376. raise mgen.error.Forbidden().describe("You cannot modify project " + proj_id)
  377. pk = self.request_params['pk']
  378. name = self.request_params['name']
  379. value = self.request_params['value']
  380. if pk == 'template':
  381. # modify template property
  382. log.debug('modify template "%s": %s=%s' % (template_id, name, value))
  383. setattr(tmpl, name, value)
  384. if 'template.params' in pk:
  385. # modify one of the params
  386. t, p, param_id = pk.split('.')
  387. log.debug('modify template parameter "%s.%s": %s=%s' % (template_id, param_id, name, value))
  388. for p in tmpl.params:
  389. if p['id'] == param_id:
  390. p[name] = value
  391. tmpl.params.changed()
  392. break
  393. if 'erase.params' in pk:
  394. # remove one of the params
  395. e, p, param_id = pk.split('.')
  396. log.debug('remove template paramter "%s.%s"' % (template_id, param_id))
  397. params = copy.deepcopy(tmpl.params)
  398. tmpl.params.clear()
  399. for p in params:
  400. if p['id'] != param_id:
  401. tmpl.params.append(p)
  402. tmpl.params.changed()
  403. if 'new.params' in pk:
  404. log.debug('new template parameter "%s.%s"' % (template_id, value['id']))
  405. tmpl.params.append(value)
  406. tmpl.params.changed()
  407. s.add(tmpl)
  408. self.commit_changes(s)
  409. return self.get_objects(mgen.model.Template,
  410. self.query(),
  411. tmpl.template_id)
  412. class Items(GenericModelHandler):
  413. """Items restfull interface"""
  414. def query(self):
  415. return session().query(mgen.model.Item).join(mgen.model.Project,
  416. mgen.model.Project.project_id==mgen.model.Item.project_id)\
  417. .join(mgen.model.project2profile,
  418. mgen.model.project2profile.c.project==mgen.model.Project.project_id)\
  419. .join(mgen.model.Profile,
  420. mgen.model.Profile.email==mgen.model.project2profile.c.profile)\
  421. .filter(mgen.model.Profile.email==self.current_profile.email)
  422. @authenticated
  423. @jsonify
  424. def get(self, item_id=None):
  425. """GET list or single project"""
  426. log.debug('REST GET %s -> %s' % (self.request.path,
  427. pprint.pformat(self.page_arguments) if item_id is None else '%s=%s' % (
  428. get_primary_key(mgen.model.Item), item_id) ))
  429. return self.get_objects(mgen.model.Item, self.query(), item_id)
  430. @authenticated
  431. @jsonify
  432. def post(self):
  433. """POST to create a new item"""
  434. log.debug("REST POST %s <- %s" % (self.request.path,
  435. pprint.pformat(self.request_params,
  436. indent=2,
  437. width=160)))
  438. self.validate_params([
  439. 'name', 'type', 'body', 'published'
  440. ])
  441. def_uri_path = self.request_params['name'].lower().replace(' ', '-').replace('/', '-').replace('\\', '-')
  442. uri_path = self.request_params.get('uri_path', def_uri_path)
  443. itm = mgen.model.Item(item_id=uuid.uuid4(),
  444. name=self.request_params['name'],
  445. type=self.request_params['type'],
  446. body=self.request_params['body'],
  447. uri_path=uri_path,
  448. published=self.request_params['published'])
  449. if itm.published:
  450. # publish it now
  451. itm.publish_date = datetime.datetime.now()
  452. elif 'publish_date' in self.request_params:
  453. itm.publish_date = datetime.datetime.strptime(self.request_params['publish_date'],
  454. '%d-%m-%Y')
  455. else:
  456. raise mgen.error.BadRequest().describe('no value given for \
  457. publish_date and item is not published now')
  458. s = session()
  459. s.add(itm)
  460. if 'tags' in self.request_params:
  461. for tag in self.request_params['tags']:
  462. tag_name = tag.strip()
  463. log.debug('applying tag "%s" to item "%s"' % (tag_name, itm.item_id))
  464. atag = s.query(mgen.model.Tag).filter_by(tag=tag_name).first()
  465. if not atag:
  466. log.debug(' -> it is a new tag, creating...')
  467. atag = mgen.model.Tag(tag=tag_name)
  468. s.add(atag)
  469. # append (new) tag to list of tags
  470. s.add(mgen.model.tag2item(tag=atag.tag, item=itm.item_id))
  471. self.commit_changes(s)
  472. self.set_status(201)
  473. log.debug('created new item: %s' % itm.item_id)
  474. return self.get_objects(mgen.model.Item, self.query(), itm.item_id)