You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

588 lines
22 KiB

  1. """CherryPy-based server for accessing NILM database via HTTP"""
  2. # Need absolute_import so that "import nilmdb" won't pull in
  3. # nilmdb.py, but will pull the nilmdb module instead.
  4. from __future__ import absolute_import
  5. import nilmdb
  6. from nilmdb.utils.printf import *
  7. from nilmdb.server.errors import NilmDBError
  8. import cherrypy
  9. import sys
  10. import os
  11. import simplejson as json
  12. import decorator
  13. import traceback
  14. import psutil
  15. class NilmApp(object):
  16. def __init__(self, db):
  17. self.db = db
  18. # Decorators
  19. def chunked_response(func):
  20. """Decorator to enable chunked responses."""
  21. # Set this to False to get better tracebacks from some requests
  22. # (/stream/extract, /stream/intervals).
  23. func._cp_config = { 'response.stream': True }
  24. return func
  25. def response_type(content_type):
  26. """Return a decorator-generating function that sets the
  27. response type to the specified string."""
  28. def wrapper(func, *args, **kwargs):
  29. cherrypy.response.headers['Content-Type'] = content_type
  30. return func(*args, **kwargs)
  31. return decorator.decorator(wrapper)
  32. @decorator.decorator
  33. def workaround_cp_bug_1200(func, *args, **kwargs): # pragma: no cover
  34. """Decorator to work around CherryPy bug #1200 in a response
  35. generator.
  36. Even if chunked responses are disabled, LookupError or
  37. UnicodeError exceptions may still be swallowed by CherryPy due to
  38. bug #1200. This throws them as generic Exceptions instead so that
  39. they make it through.
  40. """
  41. try:
  42. for val in func(*args, **kwargs):
  43. yield val
  44. except (LookupError, UnicodeError):
  45. raise Exception("bug workaround; real exception is:\n" +
  46. traceback.format_exc())
  47. def exception_to_httperror(*expected):
  48. """Return a decorator-generating function that catches expected
  49. errors and throws a HTTPError describing it instead.
  50. @exception_to_httperror(NilmDBError, ValueError)
  51. def foo():
  52. pass
  53. """
  54. def wrapper(func, *args, **kwargs):
  55. try:
  56. return func(*args, **kwargs)
  57. except expected as e:
  58. message = sprintf("%s", str(e))
  59. raise cherrypy.HTTPError("400 Bad Request", message)
  60. # We need to preserve the function's argspecs for CherryPy to
  61. # handle argument errors correctly. Decorator.decorator takes
  62. # care of that.
  63. return decorator.decorator(wrapper)
  64. # Custom CherryPy tools
  65. def CORS_allow(methods):
  66. """This does several things:
  67. Handles CORS preflight requests.
  68. Adds Allow: header to all requests.
  69. Raise 405 if request.method not in method.
  70. It is similar to cherrypy.tools.allow, with the CORS stuff added.
  71. """
  72. request = cherrypy.request.headers
  73. response = cherrypy.response.headers
  74. if not isinstance(methods, (tuple, list)): # pragma: no cover
  75. methods = [ methods ]
  76. methods = [ m.upper() for m in methods if m ]
  77. if not methods: # pragma: no cover
  78. methods = [ 'GET', 'HEAD' ]
  79. elif 'GET' in methods and 'HEAD' not in methods: # pragma: no cover
  80. methods.append('HEAD')
  81. response['Allow'] = ', '.join(methods)
  82. # Allow all origins
  83. if 'Origin' in request:
  84. response['Access-Control-Allow-Origin'] = request['Origin']
  85. # If it's a CORS request, send response.
  86. request_method = request.get("Access-Control-Request-Method", None)
  87. request_headers = request.get("Access-Control-Request-Headers", None)
  88. if (cherrypy.request.method == "OPTIONS" and
  89. request_method and request_headers):
  90. response['Access-Control-Allow-Headers'] = request_headers
  91. response['Access-Control-Allow-Methods'] = ', '.join(methods)
  92. # Try to stop further processing and return a 200 OK
  93. cherrypy.response.status = "200 OK"
  94. cherrypy.response.body = ""
  95. cherrypy.request.handler = lambda: ""
  96. return
  97. # Reject methods that were not explicitly allowed
  98. if cherrypy.request.method not in methods:
  99. raise cherrypy.HTTPError(405)
  100. cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
  101. # Helper for json_in tool to process JSON data into normal request
  102. # parameters.
  103. def json_to_request_params(body):
  104. cherrypy.lib.jsontools.json_processor(body)
  105. if not isinstance(cherrypy.request.json, dict):
  106. raise cherrypy.HTTPError(415)
  107. cherrypy.request.params.update(cherrypy.request.json)
  108. # CherryPy apps
  109. class Root(NilmApp):
  110. """Root application for NILM database"""
  111. def __init__(self, db):
  112. super(Root, self).__init__(db)
  113. # /
  114. @cherrypy.expose
  115. def index(self):
  116. raise cherrypy.NotFound()
  117. # /favicon.ico
  118. @cherrypy.expose
  119. def favicon_ico(self):
  120. raise cherrypy.NotFound()
  121. # /version
  122. @cherrypy.expose
  123. @cherrypy.tools.json_out()
  124. def version(self):
  125. return nilmdb.__version__
  126. # /dbinfo
  127. @cherrypy.expose
  128. @cherrypy.tools.json_out()
  129. def dbinfo(self):
  130. """Return a dictionary with the database path,
  131. size of the database in bytes, and free disk space in bytes"""
  132. path = self.db.get_basepath()
  133. return { "path": path,
  134. "size": nilmdb.utils.du(path),
  135. "free": psutil.disk_usage(path).free }
  136. class Stream(NilmApp):
  137. """Stream-specific operations"""
  138. # /stream/list
  139. # /stream/list?layout=PrepData
  140. # /stream/list?path=/newton/prep&extent=1
  141. @cherrypy.expose
  142. @cherrypy.tools.json_out()
  143. def list(self, path = None, layout = None, extent = None):
  144. """List all streams in the database. With optional path or
  145. layout parameter, just list streams that match the given path
  146. or layout.
  147. If extent is not given, returns a list of lists containing
  148. the path and layout: [ path, layout ]
  149. If extent is provided, returns a list of lists containing the
  150. path, layout, and min/max extent of the data:
  151. [ path, layout, extent_min, extent_max ]
  152. """
  153. return self.db.stream_list(path, layout, bool(extent))
  154. # /stream/create?path=/newton/prep&layout=PrepData
  155. @cherrypy.expose
  156. @cherrypy.tools.json_in()
  157. @cherrypy.tools.json_out()
  158. @exception_to_httperror(NilmDBError, ValueError)
  159. @cherrypy.tools.CORS_allow(methods = ["POST"])
  160. def create(self, path, layout):
  161. """Create a new stream in the database. Provide path
  162. and one of the nilmdb.layout.layouts keys.
  163. """
  164. return self.db.stream_create(path, layout)
  165. # /stream/destroy?path=/newton/prep
  166. @cherrypy.expose
  167. @cherrypy.tools.json_in()
  168. @cherrypy.tools.json_out()
  169. @exception_to_httperror(NilmDBError)
  170. @cherrypy.tools.CORS_allow(methods = ["POST"])
  171. def destroy(self, path):
  172. """Delete a stream and its associated data."""
  173. return self.db.stream_destroy(path)
  174. # /stream/get_metadata?path=/newton/prep
  175. # /stream/get_metadata?path=/newton/prep&key=foo&key=bar
  176. @cherrypy.expose
  177. @cherrypy.tools.json_out()
  178. def get_metadata(self, path, key=None):
  179. """Get metadata for the named stream. If optional
  180. key parameters are specified, only return metadata
  181. matching the given keys."""
  182. try:
  183. data = self.db.stream_get_metadata(path)
  184. except nilmdb.server.nilmdb.StreamError as e:
  185. raise cherrypy.HTTPError("404 Not Found", e.message)
  186. if key is None: # If no keys specified, return them all
  187. key = data.keys()
  188. elif not isinstance(key, list):
  189. key = [ key ]
  190. result = {}
  191. for k in key:
  192. if k in data:
  193. result[k] = data[k]
  194. else: # Return "None" for keys with no matching value
  195. result[k] = None
  196. return result
  197. # Helper for set_metadata and get_metadata
  198. def _metadata_helper(self, function, path, data):
  199. if not isinstance(data, dict):
  200. try:
  201. data = dict(json.loads(data))
  202. except TypeError as e:
  203. raise NilmDBError("can't parse 'data' parameter: " + e.message)
  204. for key in data:
  205. if not (isinstance(data[key], basestring) or
  206. isinstance(data[key], float) or
  207. isinstance(data[key], int)):
  208. raise NilmDBError("metadata values must be a string or number")
  209. function(path, data)
  210. # /stream/set_metadata?path=/newton/prep&data=<json>
  211. @cherrypy.expose
  212. @cherrypy.tools.json_in()
  213. @cherrypy.tools.json_out()
  214. @exception_to_httperror(NilmDBError, LookupError)
  215. @cherrypy.tools.CORS_allow(methods = ["POST"])
  216. def set_metadata(self, path, data):
  217. """Set metadata for the named stream, replacing any existing
  218. metadata. Data can be json-encoded or a plain dictionary."""
  219. self._metadata_helper(self.db.stream_set_metadata, path, data)
  220. # /stream/update_metadata?path=/newton/prep&data=<json>
  221. @cherrypy.expose
  222. @cherrypy.tools.json_in()
  223. @cherrypy.tools.json_out()
  224. @exception_to_httperror(NilmDBError, LookupError, ValueError)
  225. @cherrypy.tools.CORS_allow(methods = ["POST"])
  226. def update_metadata(self, path, data):
  227. """Set metadata for the named stream, replacing any existing
  228. metadata. Data can be json-encoded or a plain dictionary."""
  229. self._metadata_helper(self.db.stream_update_metadata, path, data)
  230. # /stream/insert?path=/newton/prep
  231. @cherrypy.expose
  232. @cherrypy.tools.json_out()
  233. @cherrypy.tools.CORS_allow(methods = ["PUT"])
  234. def insert(self, path, start, end):
  235. """
  236. Insert new data into the database. Provide textual data
  237. (matching the path's layout) as a HTTP PUT.
  238. """
  239. # Important that we always read the input before throwing any
  240. # errors, to keep lengths happy for persistent connections.
  241. # Note that CherryPy 3.2.2 has a bug where this fails for GET
  242. # requests, if we ever want to handle those (issue #1134)
  243. body = cherrypy.request.body.read()
  244. # Check path and get layout
  245. streams = self.db.stream_list(path = path)
  246. if len(streams) != 1:
  247. raise cherrypy.HTTPError("404 Not Found", "No such stream")
  248. layout = streams[0][1]
  249. # Parse the input data
  250. try:
  251. parser = nilmdb.server.layout.Parser(layout)
  252. parser.parse(body)
  253. except nilmdb.server.layout.ParserError as e:
  254. raise cherrypy.HTTPError("400 Bad Request",
  255. "error parsing input data: " +
  256. e.message)
  257. # Check limits
  258. start = float(start)
  259. end = float(end)
  260. if start >= end:
  261. raise cherrypy.HTTPError("400 Bad Request",
  262. "start must precede end")
  263. if parser.min_timestamp is not None and parser.min_timestamp < start:
  264. raise cherrypy.HTTPError("400 Bad Request", "Data timestamp " +
  265. repr(parser.min_timestamp) +
  266. " < start time " + repr(start))
  267. if parser.max_timestamp is not None and parser.max_timestamp >= end:
  268. raise cherrypy.HTTPError("400 Bad Request", "Data timestamp " +
  269. repr(parser.max_timestamp) +
  270. " >= end time " + repr(end))
  271. # Now do the nilmdb insert, passing it the parser full of data.
  272. try:
  273. self.db.stream_insert(path, start, end, parser.data)
  274. except NilmDBError as e:
  275. raise cherrypy.HTTPError("400 Bad Request", e.message)
  276. # Done
  277. return
  278. # /stream/remove?path=/newton/prep
  279. # /stream/remove?path=/newton/prep&start=1234567890.0&end=1234567899.0
  280. @cherrypy.expose
  281. @cherrypy.tools.json_in()
  282. @cherrypy.tools.json_out()
  283. @exception_to_httperror(NilmDBError)
  284. @cherrypy.tools.CORS_allow(methods = ["POST"])
  285. def remove(self, path, start = None, end = None):
  286. """
  287. Remove data from the backend database. Removes all data in
  288. the interval [start, end). Returns the number of data points
  289. removed.
  290. """
  291. if start is not None:
  292. start = float(start)
  293. if end is not None:
  294. end = float(end)
  295. if start is not None and end is not None:
  296. if start >= end:
  297. raise cherrypy.HTTPError("400 Bad Request",
  298. "start must precede end")
  299. return self.db.stream_remove(path, start, end)
  300. # /stream/intervals?path=/newton/prep
  301. # /stream/intervals?path=/newton/prep&start=1234567890.0&end=1234567899.0
  302. @cherrypy.expose
  303. @chunked_response
  304. @response_type("application/x-json-stream")
  305. def intervals(self, path, start = None, end = None):
  306. """
  307. Get intervals from backend database. Streams the resulting
  308. intervals as JSON strings separated by CR LF pairs. This may
  309. make multiple requests to the nilmdb backend to avoid causing
  310. it to block for too long.
  311. Note that the response type is the non-standard
  312. 'application/x-json-stream' for lack of a better option.
  313. """
  314. if start is not None:
  315. start = float(start)
  316. if end is not None:
  317. end = float(end)
  318. if start is not None and end is not None:
  319. if start >= end:
  320. raise cherrypy.HTTPError("400 Bad Request",
  321. "start must precede end")
  322. streams = self.db.stream_list(path = path)
  323. if len(streams) != 1:
  324. raise cherrypy.HTTPError("404 Not Found", "No such stream")
  325. @workaround_cp_bug_1200
  326. def content(start, end):
  327. # Note: disable chunked responses to see tracebacks from here.
  328. while True:
  329. (ints, restart) = self.db.stream_intervals(path, start, end)
  330. response = ''.join([ json.dumps(i) + "\r\n" for i in ints ])
  331. yield response
  332. if restart == 0:
  333. break
  334. start = restart
  335. return content(start, end)
  336. # /stream/extract?path=/newton/prep&start=1234567890.0&end=1234567899.0
  337. @cherrypy.expose
  338. @chunked_response
  339. @response_type("text/plain")
  340. def extract(self, path, start = None, end = None, count = False):
  341. """
  342. Extract data from backend database. Streams the resulting
  343. entries as ASCII text lines separated by newlines. This may
  344. make multiple requests to the nilmdb backend to avoid causing
  345. it to block for too long.
  346. Add count=True to return a count rather than actual data.
  347. """
  348. if start is not None:
  349. start = float(start)
  350. if end is not None:
  351. end = float(end)
  352. # Check parameters
  353. if start is not None and end is not None:
  354. if start >= end:
  355. raise cherrypy.HTTPError("400 Bad Request",
  356. "start must precede end")
  357. # Check path and get layout
  358. streams = self.db.stream_list(path = path)
  359. if len(streams) != 1:
  360. raise cherrypy.HTTPError("404 Not Found", "No such stream")
  361. layout = streams[0][1]
  362. # Get formatter
  363. formatter = nilmdb.server.layout.Formatter(layout)
  364. @workaround_cp_bug_1200
  365. def content(start, end, count):
  366. # Note: disable chunked responses to see tracebacks from here.
  367. if count:
  368. matched = self.db.stream_extract(path, start, end, count)
  369. yield sprintf("%d\n", matched)
  370. return
  371. while True:
  372. (data, restart) = self.db.stream_extract(path, start, end)
  373. # Format the data and yield it
  374. yield formatter.format(data)
  375. if restart == 0:
  376. return
  377. start = restart
  378. return content(start, end, count)
  379. class Exiter(object):
  380. """App that exits the server, for testing"""
  381. @cherrypy.expose
  382. def index(self):
  383. cherrypy.response.headers['Content-Type'] = 'text/plain'
  384. def content():
  385. yield 'Exiting by request'
  386. raise SystemExit
  387. return content()
  388. index._cp_config = { 'response.stream': True }
  389. class Server(object):
  390. def __init__(self, db, host = '127.0.0.1', port = 8080,
  391. stoppable = False, # whether /exit URL exists
  392. embedded = True, # hide diagnostics and output, etc
  393. fast_shutdown = False, # don't wait for clients to disconn.
  394. force_traceback = False # include traceback in all errors
  395. ):
  396. # Save server version, just for verification during tests
  397. self.version = nilmdb.__version__
  398. self.embedded = embedded
  399. self.db = db
  400. if not getattr(db, "_thread_safe", None):
  401. raise KeyError("Database object " + str(db) + " doesn't claim "
  402. "to be thread safe. You should pass "
  403. "nilmdb.utils.serializer_proxy(NilmDB)(args) "
  404. "rather than NilmDB(args).")
  405. # Build up global server configuration
  406. cherrypy.config.update({
  407. 'server.socket_host': host,
  408. 'server.socket_port': port,
  409. 'engine.autoreload_on': False,
  410. 'server.max_request_body_size': 8*1024*1024,
  411. })
  412. if self.embedded:
  413. cherrypy.config.update({ 'environment': 'embedded' })
  414. # Build up application specific configuration
  415. app_config = {}
  416. app_config.update({
  417. 'error_page.default': self.json_error_page,
  418. })
  419. # Some default headers to just help identify that things are working
  420. app_config.update({ 'response.headers.X-Jim-Is-Awesome': 'yeah' })
  421. # Set up Cross-Origin Resource Sharing (CORS) handler so we
  422. # can correctly respond to browsers' CORS preflight requests.
  423. # This also limits verbs to GET and HEAD by default.
  424. app_config.update({ 'tools.CORS_allow.on': True,
  425. 'tools.CORS_allow.methods': ['GET', 'HEAD'] })
  426. # Configure the 'json_in' tool to also allow other content-types
  427. # (like x-www-form-urlencoded), and to treat JSON as a dict that
  428. # fills requests.param.
  429. app_config.update({ 'tools.json_in.force': False,
  430. 'tools.json_in.processor': json_to_request_params })
  431. # Send tracebacks in error responses. They're hidden by the
  432. # error_page function for client errors (code 400-499).
  433. app_config.update({ 'request.show_tracebacks' : True })
  434. self.force_traceback = force_traceback
  435. # Patch CherryPy error handler to never pad out error messages.
  436. # This isn't necessary, but then again, neither is padding the
  437. # error messages.
  438. cherrypy._cperror._ie_friendly_error_sizes = {}
  439. # Build up the application and mount it
  440. root = Root(self.db)
  441. root.stream = Stream(self.db)
  442. if stoppable:
  443. root.exit = Exiter()
  444. cherrypy.tree.apps = {}
  445. cherrypy.tree.mount(root, "/", config = { "/" : app_config })
  446. # Shutdowns normally wait for clients to disconnect. To speed
  447. # up tests, set fast_shutdown = True
  448. if fast_shutdown:
  449. # Setting timeout to 0 triggers os._exit(70) at shutdown, grr...
  450. cherrypy.server.shutdown_timeout = 0.01
  451. else:
  452. cherrypy.server.shutdown_timeout = 5
  453. def json_error_page(self, status, message, traceback, version):
  454. """Return a custom error page in JSON so the client can parse it"""
  455. errordata = { "status" : status,
  456. "message" : message,
  457. "traceback" : traceback }
  458. # Don't send a traceback if the error was 400-499 (client's fault)
  459. try:
  460. code = int(status.split()[0])
  461. if not self.force_traceback:
  462. if code >= 400 and code <= 499:
  463. errordata["traceback"] = ""
  464. except Exception: # pragma: no cover
  465. pass
  466. # Override the response type, which was previously set to text/html
  467. cherrypy.serving.response.headers['Content-Type'] = (
  468. "application/json;charset=utf-8" )
  469. # Undo the HTML escaping that cherrypy's get_error_page function applies
  470. # (cherrypy issue 1135)
  471. for k, v in errordata.iteritems():
  472. v = v.replace("&lt;","<")
  473. v = v.replace("&gt;",">")
  474. v = v.replace("&amp;","&")
  475. errordata[k] = v
  476. return json.dumps(errordata, separators=(',',':'))
  477. def start(self, blocking = False, event = None):
  478. if not self.embedded: # pragma: no cover
  479. # Handle signals nicely
  480. if hasattr(cherrypy.engine, "signal_handler"):
  481. cherrypy.engine.signal_handler.subscribe()
  482. if hasattr(cherrypy.engine, "console_control_handler"):
  483. cherrypy.engine.console_control_handler.subscribe()
  484. # Cherrypy stupidly calls os._exit(70) when it can't bind the
  485. # port. At least try to print a reasonable error and continue
  486. # in this case, rather than just dying silently (as we would
  487. # otherwise do in embedded mode)
  488. real_exit = os._exit
  489. def fake_exit(code): # pragma: no cover
  490. if code == os.EX_SOFTWARE:
  491. fprintf(sys.stderr, "error: CherryPy called os._exit!\n")
  492. else:
  493. real_exit(code)
  494. os._exit = fake_exit
  495. cherrypy.engine.start()
  496. os._exit = real_exit
  497. # Signal that the engine has started successfully
  498. if event is not None:
  499. event.set()
  500. if blocking:
  501. try:
  502. cherrypy.engine.wait(cherrypy.engine.states.EXITING,
  503. interval = 0.1, channel = 'main')
  504. except (KeyboardInterrupt, IOError): # pragma: no cover
  505. cherrypy.engine.log('Keyboard Interrupt: shutting down bus')
  506. cherrypy.engine.exit()
  507. except SystemExit: # pragma: no cover
  508. cherrypy.engine.log('SystemExit raised: shutting down bus')
  509. cherrypy.engine.exit()
  510. raise
  511. def stop(self):
  512. cherrypy.engine.exit()