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.
 
 
 

577 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. # /stream/set_metadata?path=/newton/prep&data=<json>
  198. @cherrypy.expose
  199. @cherrypy.tools.json_in()
  200. @cherrypy.tools.json_out()
  201. @exception_to_httperror(NilmDBError, LookupError, TypeError)
  202. @cherrypy.tools.CORS_allow(methods = ["POST"])
  203. def set_metadata(self, path, data):
  204. """Set metadata for the named stream, replacing any
  205. existing metadata. Data should be a json-encoded
  206. dictionary"""
  207. data_dict = json.loads(data)
  208. self.db.stream_set_metadata(path, data_dict)
  209. # /stream/update_metadata?path=/newton/prep&data=<json>
  210. @cherrypy.expose
  211. @cherrypy.tools.json_in()
  212. @cherrypy.tools.json_out()
  213. @exception_to_httperror(NilmDBError, LookupError, TypeError)
  214. @cherrypy.tools.CORS_allow(methods = ["POST"])
  215. def update_metadata(self, path, data):
  216. """Update metadata for the named stream. Data
  217. should be a json-encoded dictionary"""
  218. data_dict = json.loads(data)
  219. self.db.stream_update_metadata(path, data_dict)
  220. # /stream/insert?path=/newton/prep
  221. @cherrypy.expose
  222. @cherrypy.tools.json_out()
  223. @cherrypy.tools.CORS_allow(methods = ["PUT"])
  224. def insert(self, path, start, end):
  225. """
  226. Insert new data into the database. Provide textual data
  227. (matching the path's layout) as a HTTP PUT.
  228. """
  229. # Important that we always read the input before throwing any
  230. # errors, to keep lengths happy for persistent connections.
  231. # Note that CherryPy 3.2.2 has a bug where this fails for GET
  232. # requests, if we ever want to handle those (issue #1134)
  233. body = cherrypy.request.body.read()
  234. # Check path and get layout
  235. streams = self.db.stream_list(path = path)
  236. if len(streams) != 1:
  237. raise cherrypy.HTTPError("404 Not Found", "No such stream")
  238. layout = streams[0][1]
  239. # Parse the input data
  240. try:
  241. parser = nilmdb.server.layout.Parser(layout)
  242. parser.parse(body)
  243. except nilmdb.server.layout.ParserError as e:
  244. raise cherrypy.HTTPError("400 Bad Request",
  245. "error parsing input data: " +
  246. e.message)
  247. # Check limits
  248. start = float(start)
  249. end = float(end)
  250. if start >= end:
  251. raise cherrypy.HTTPError("400 Bad Request",
  252. "start must precede end")
  253. if parser.min_timestamp is not None and parser.min_timestamp < start:
  254. raise cherrypy.HTTPError("400 Bad Request", "Data timestamp " +
  255. repr(parser.min_timestamp) +
  256. " < start time " + repr(start))
  257. if parser.max_timestamp is not None and parser.max_timestamp >= end:
  258. raise cherrypy.HTTPError("400 Bad Request", "Data timestamp " +
  259. repr(parser.max_timestamp) +
  260. " >= end time " + repr(end))
  261. # Now do the nilmdb insert, passing it the parser full of data.
  262. try:
  263. self.db.stream_insert(path, start, end, parser.data)
  264. except NilmDBError as e:
  265. raise cherrypy.HTTPError("400 Bad Request", e.message)
  266. # Done
  267. return
  268. # /stream/remove?path=/newton/prep
  269. # /stream/remove?path=/newton/prep&start=1234567890.0&end=1234567899.0
  270. @cherrypy.expose
  271. @cherrypy.tools.json_in()
  272. @cherrypy.tools.json_out()
  273. @exception_to_httperror(NilmDBError)
  274. @cherrypy.tools.CORS_allow(methods = ["POST"])
  275. def remove(self, path, start = None, end = None):
  276. """
  277. Remove data from the backend database. Removes all data in
  278. the interval [start, end). Returns the number of data points
  279. removed.
  280. """
  281. if start is not None:
  282. start = float(start)
  283. if end is not None:
  284. end = float(end)
  285. if start is not None and end is not None:
  286. if start >= end:
  287. raise cherrypy.HTTPError("400 Bad Request",
  288. "start must precede end")
  289. return self.db.stream_remove(path, start, end)
  290. # /stream/intervals?path=/newton/prep
  291. # /stream/intervals?path=/newton/prep&start=1234567890.0&end=1234567899.0
  292. @cherrypy.expose
  293. @chunked_response
  294. @response_type("application/x-json-stream")
  295. def intervals(self, path, start = None, end = None):
  296. """
  297. Get intervals from backend database. Streams the resulting
  298. intervals as JSON strings separated by CR LF pairs. This may
  299. make multiple requests to the nilmdb backend to avoid causing
  300. it to block for too long.
  301. Note that the response type is the non-standard
  302. 'application/x-json-stream' for lack of a better option.
  303. """
  304. if start is not None:
  305. start = float(start)
  306. if end is not None:
  307. end = float(end)
  308. if start is not None and end is not None:
  309. if start >= end:
  310. raise cherrypy.HTTPError("400 Bad Request",
  311. "start must precede end")
  312. streams = self.db.stream_list(path = path)
  313. if len(streams) != 1:
  314. raise cherrypy.HTTPError("404 Not Found", "No such stream")
  315. @workaround_cp_bug_1200
  316. def content(start, end):
  317. # Note: disable chunked responses to see tracebacks from here.
  318. while True:
  319. (ints, restart) = self.db.stream_intervals(path, start, end)
  320. response = ''.join([ json.dumps(i) + "\r\n" for i in ints ])
  321. yield response
  322. if restart == 0:
  323. break
  324. start = restart
  325. return content(start, end)
  326. # /stream/extract?path=/newton/prep&start=1234567890.0&end=1234567899.0
  327. @cherrypy.expose
  328. @chunked_response
  329. @response_type("text/plain")
  330. def extract(self, path, start = None, end = None, count = False):
  331. """
  332. Extract data from backend database. Streams the resulting
  333. entries as ASCII text lines separated by newlines. This may
  334. make multiple requests to the nilmdb backend to avoid causing
  335. it to block for too long.
  336. Add count=True to return a count rather than actual data.
  337. """
  338. if start is not None:
  339. start = float(start)
  340. if end is not None:
  341. end = float(end)
  342. # Check parameters
  343. if start is not None and end is not None:
  344. if start >= end:
  345. raise cherrypy.HTTPError("400 Bad Request",
  346. "start must precede end")
  347. # Check path and get layout
  348. streams = self.db.stream_list(path = path)
  349. if len(streams) != 1:
  350. raise cherrypy.HTTPError("404 Not Found", "No such stream")
  351. layout = streams[0][1]
  352. # Get formatter
  353. formatter = nilmdb.server.layout.Formatter(layout)
  354. @workaround_cp_bug_1200
  355. def content(start, end, count):
  356. # Note: disable chunked responses to see tracebacks from here.
  357. if count:
  358. matched = self.db.stream_extract(path, start, end, count)
  359. yield sprintf("%d\n", matched)
  360. return
  361. while True:
  362. (data, restart) = self.db.stream_extract(path, start, end)
  363. # Format the data and yield it
  364. yield formatter.format(data)
  365. if restart == 0:
  366. return
  367. start = restart
  368. return content(start, end, count)
  369. class Exiter(object):
  370. """App that exits the server, for testing"""
  371. @cherrypy.expose
  372. def index(self):
  373. cherrypy.response.headers['Content-Type'] = 'text/plain'
  374. def content():
  375. yield 'Exiting by request'
  376. raise SystemExit
  377. return content()
  378. index._cp_config = { 'response.stream': True }
  379. class Server(object):
  380. def __init__(self, db, host = '127.0.0.1', port = 8080,
  381. stoppable = False, # whether /exit URL exists
  382. embedded = True, # hide diagnostics and output, etc
  383. fast_shutdown = False, # don't wait for clients to disconn.
  384. force_traceback = False # include traceback in all errors
  385. ):
  386. # Save server version, just for verification during tests
  387. self.version = nilmdb.__version__
  388. self.embedded = embedded
  389. self.db = db
  390. if not getattr(db, "_thread_safe", None):
  391. raise KeyError("Database object " + str(db) + " doesn't claim "
  392. "to be thread safe. You should pass "
  393. "nilmdb.utils.serializer_proxy(NilmDB)(args) "
  394. "rather than NilmDB(args).")
  395. # Build up global server configuration
  396. cherrypy.config.update({
  397. 'server.socket_host': host,
  398. 'server.socket_port': port,
  399. 'engine.autoreload_on': False,
  400. 'server.max_request_body_size': 8*1024*1024,
  401. })
  402. if self.embedded:
  403. cherrypy.config.update({ 'environment': 'embedded' })
  404. # Build up application specific configuration
  405. app_config = {}
  406. app_config.update({
  407. 'error_page.default': self.json_error_page,
  408. })
  409. # Some default headers to just help identify that things are working
  410. app_config.update({ 'response.headers.X-Jim-Is-Awesome': 'yeah' })
  411. # Set up Cross-Origin Resource Sharing (CORS) handler so we
  412. # can correctly respond to browsers' CORS preflight requests.
  413. # This also limits verbs to GET and HEAD by default.
  414. app_config.update({ 'tools.CORS_allow.on': True,
  415. 'tools.CORS_allow.methods': ['GET', 'HEAD'] })
  416. # Configure the 'json_in' tool to also allow other content-types
  417. # (like x-www-form-urlencoded), and to treat JSON as a dict that
  418. # fills requests.param.
  419. app_config.update({ 'tools.json_in.force': False,
  420. 'tools.json_in.processor': json_to_request_params })
  421. # Send tracebacks in error responses. They're hidden by the
  422. # error_page function for client errors (code 400-499).
  423. app_config.update({ 'request.show_tracebacks' : True })
  424. self.force_traceback = force_traceback
  425. # Patch CherryPy error handler to never pad out error messages.
  426. # This isn't necessary, but then again, neither is padding the
  427. # error messages.
  428. cherrypy._cperror._ie_friendly_error_sizes = {}
  429. # Build up the application and mount it
  430. root = Root(self.db)
  431. root.stream = Stream(self.db)
  432. if stoppable:
  433. root.exit = Exiter()
  434. cherrypy.tree.apps = {}
  435. cherrypy.tree.mount(root, "/", config = { "/" : app_config })
  436. # Shutdowns normally wait for clients to disconnect. To speed
  437. # up tests, set fast_shutdown = True
  438. if fast_shutdown:
  439. # Setting timeout to 0 triggers os._exit(70) at shutdown, grr...
  440. cherrypy.server.shutdown_timeout = 0.01
  441. else:
  442. cherrypy.server.shutdown_timeout = 5
  443. def json_error_page(self, status, message, traceback, version):
  444. """Return a custom error page in JSON so the client can parse it"""
  445. errordata = { "status" : status,
  446. "message" : message,
  447. "traceback" : traceback }
  448. # Don't send a traceback if the error was 400-499 (client's fault)
  449. try:
  450. code = int(status.split()[0])
  451. if not self.force_traceback:
  452. if code >= 400 and code <= 499:
  453. errordata["traceback"] = ""
  454. except Exception: # pragma: no cover
  455. pass
  456. # Override the response type, which was previously set to text/html
  457. cherrypy.serving.response.headers['Content-Type'] = (
  458. "application/json;charset=utf-8" )
  459. # Undo the HTML escaping that cherrypy's get_error_page function applies
  460. # (cherrypy issue 1135)
  461. for k, v in errordata.iteritems():
  462. v = v.replace("&lt;","<")
  463. v = v.replace("&gt;",">")
  464. v = v.replace("&amp;","&")
  465. errordata[k] = v
  466. return json.dumps(errordata, separators=(',',':'))
  467. def start(self, blocking = False, event = None):
  468. if not self.embedded: # pragma: no cover
  469. # Handle signals nicely
  470. if hasattr(cherrypy.engine, "signal_handler"):
  471. cherrypy.engine.signal_handler.subscribe()
  472. if hasattr(cherrypy.engine, "console_control_handler"):
  473. cherrypy.engine.console_control_handler.subscribe()
  474. # Cherrypy stupidly calls os._exit(70) when it can't bind the
  475. # port. At least try to print a reasonable error and continue
  476. # in this case, rather than just dying silently (as we would
  477. # otherwise do in embedded mode)
  478. real_exit = os._exit
  479. def fake_exit(code): # pragma: no cover
  480. if code == os.EX_SOFTWARE:
  481. fprintf(sys.stderr, "error: CherryPy called os._exit!\n")
  482. else:
  483. real_exit(code)
  484. os._exit = fake_exit
  485. cherrypy.engine.start()
  486. os._exit = real_exit
  487. # Signal that the engine has started successfully
  488. if event is not None:
  489. event.set()
  490. if blocking:
  491. try:
  492. cherrypy.engine.wait(cherrypy.engine.states.EXITING,
  493. interval = 0.1, channel = 'main')
  494. except (KeyboardInterrupt, IOError): # pragma: no cover
  495. cherrypy.engine.log('Keyboard Interrupt: shutting down bus')
  496. cherrypy.engine.exit()
  497. except SystemExit: # pragma: no cover
  498. cherrypy.engine.log('SystemExit raised: shutting down bus')
  499. cherrypy.engine.exit()
  500. raise
  501. def stop(self):
  502. cherrypy.engine.exit()