diff --git a/examples/builder.py b/examples/builder.py index 35df2b5c..19f1156c 100644 --- a/examples/builder.py +++ b/examples/builder.py @@ -21,6 +21,7 @@ def cleanup(): title="My Lab Device API", description="Test LabThing-based API", version="0.1.0", + types=["org.labthings.examples.builder"], ) # Attach an instance of our component diff --git a/labthings/server/decorators.py b/labthings/server/decorators.py index f672d4f9..2c89d402 100644 --- a/labthings/server/decorators.py +++ b/labthings/server/decorators.py @@ -286,7 +286,10 @@ def __init__(self, code, description=None, mimetype=None, **kwargs): if self.mimetype: self.response_dict.update( - {"responses": {self.code: {"content": {self.mimetype: {}}}}} + { + "responses": {self.code: {"content": {self.mimetype: {}}}}, + "_content_type": self.mimetype, + } ) def __call__(self, f): diff --git a/labthings/server/find.py b/labthings/server/find.py index b4f78146..c59a771e 100644 --- a/labthings/server/find.py +++ b/labthings/server/find.py @@ -4,7 +4,7 @@ from . import EXTENSION_NAME -def current_labthing(): +def current_labthing(app=None): """The LabThing instance handling current requests. Searches for a valid LabThing extension attached to the current Flask context. @@ -12,14 +12,15 @@ def current_labthing(): # We use _get_current_object so that Task threads can still # reach the Flask app object. Just using current_app returns # a wrapper, which breaks it's use in Task threads - app = current_app._get_current_object() # skipcq: PYL-W0212 + if not app: + app = current_app._get_current_object() # skipcq: PYL-W0212 if not app: return None logging.debug("Active app extensions:") logging.debug(app.extensions) logging.debug("Active labthing:") logging.debug(app.extensions[EXTENSION_NAME]) - return app.extensions[EXTENSION_NAME] + return app.extensions.get(EXTENSION_NAME, None) def registered_extensions(labthing_instance=None): diff --git a/labthings/server/labthing.py b/labthings/server/labthing.py index 185e08cd..e79a6ac2 100644 --- a/labthings/server/labthing.py +++ b/labthings/server/labthing.py @@ -28,6 +28,7 @@ def __init__( prefix: str = "", title: str = "", description: str = "", + types: list = [], version: str = "0.0.0", ): self.app = app # Becomes a Flask app @@ -46,6 +47,14 @@ def __init__( self.endpoints = set() self.url_prefix = prefix + + for t in types: + if ";" in t: + raise ValueError( + f'Error in type value "{t}". Thing types cannot contain ; character.' + ) + self.types = types + self._description = description self._title = title self._version = version diff --git a/labthings/server/quick.py b/labthings/server/quick.py index 914d17a0..d3691f76 100644 --- a/labthings/server/quick.py +++ b/labthings/server/quick.py @@ -11,6 +11,7 @@ def create_app( prefix: str = "", title: str = "", description: str = "", + types: list = [], version: str = "0.0.0", handle_errors: bool = True, handle_cors: bool = True, @@ -52,7 +53,12 @@ def create_app( # Create a LabThing labthing = LabThing( - app, prefix=prefix, title=title, description=description, version=str(version) + app, + prefix=prefix, + title=title, + description=description, + types=types, + version=str(version), ) # Store references to added-in handlers diff --git a/labthings/server/sockets/eventlet.py b/labthings/server/sockets/eventlet.py deleted file mode 100644 index 178c07a7..00000000 --- a/labthings/server/sockets/eventlet.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- - -from werkzeug.exceptions import NotFound -from werkzeug.http import parse_cookie -from werkzeug.wrappers import Request -from flask import request -from pprint import pformat -import logging - -from .base import BaseSockets, process_socket_message -from eventlet import websocket -import eventlet - - -class SocketMiddleware(object): - def __init__(self, wsgi_app, app, socket): - self.ws = socket - self.app = app - self.wsgi_app = wsgi_app - - def __call__(self, environ, start_response): - request = Request(environ) - adapter = self.ws.url_map.bind_to_environ(environ) - - logging.debug(pformat(environ)) - - if environ.get("HTTP_UPGRADE") == "websocket": - try: # Try matching to a Sockets route - handler, values = adapter.match() - cookie = None - if "HTTP_COOKIE" in environ: - cookie = parse_cookie(environ["HTTP_COOKIE"]) - - with self.app.app_context(): - with self.app.request_context(environ): - # add cookie to the request to have correct session handling - request.cookie = cookie - - websocket.WebSocketWSGI(handler)( - environ, start_response, **values - ) - return [] - except (NotFound, KeyError): # If no socket route found, fall back to WSGI - return self.wsgi_app(environ, start_response) - else: # If not upgrading to a websocket - return self.wsgi_app(environ, start_response) - - -class Sockets(BaseSockets): - def init_app(self, app): - app.wsgi_app = SocketMiddleware(app.wsgi_app, app, self) - - -def socket_handler_loop(ws): - while True: - message = ws.wait() - if message is None: - break - response = process_socket_message(message) - if response: - ws.send(response) diff --git a/labthings/server/spec/td.py b/labthings/server/spec/td.py index 56d9a8bd..fae02851 100644 --- a/labthings/server/spec/td.py +++ b/labthings/server/spec/td.py @@ -3,7 +3,7 @@ from ..view import View -from .utilities import get_spec, convert_schema, schema_to_json +from .utilities import get_spec, convert_schema, schema_to_json, get_topmost_spec_attr from .paths import rule_to_params, rule_to_path from ..find import current_labthing @@ -79,12 +79,17 @@ def add_link(self, view, rel, kwargs=None, params=None): def to_dict(self): return { "@context": "https://www.w3.org/2019/wot/td/v1", + "@type": current_labthing().types, "id": url_for("root", _external=True), + "base": url_for("root", _external=True), "title": current_labthing().title, "description": current_labthing().description, "properties": self.properties, "actions": self.actions, "links": self.links, + # TODO: Add proper security schemes + "securityDefinitions": {"nosec_sc": {"scheme": "nosec"}}, + "security": ["nosec_sc"], } def view_to_thing_property(self, rules: list, view: View): @@ -104,6 +109,7 @@ def view_to_thing_property(self, rules: list, view: View): "writeOnly": not hasattr(view, "get"), # TODO: Make URLs absolute "links": [{"href": f"{url}"} for url in prop_urls], + "forms": self.view_to_thing_property_forms(rules, view), "uriVariables": {}, } @@ -134,6 +140,20 @@ def view_to_thing_property(self, rules: list, view: View): return prop_description + def view_to_thing_property_forms(self, rules: list, view: View): + readable = ( + hasattr(view, "post") or hasattr(view, "put") or hasattr(view, "delete") + ) + writeable = hasattr(view, "get") + + op = [] + if readable: + op.append("readproperty") + if writeable: + op.append("writeproperty") + + return self.build_forms_for_view(rules, view, op=op) + def view_to_thing_action(self, rules: list, view: View): action_urls = [rule_to_path(rule) for rule in rules] @@ -145,10 +165,14 @@ def view_to_thing_action(self, rules: list, view: View): or (get_docstring(view.post) if hasattr(view, "post") else ""), # TODO: Make URLs absolute "links": [{"href": f"{url}"} for url in action_urls], + "forms": self.view_to_thing_action_forms(rules, view), } return action_description + def view_to_thing_action_forms(self, rules: list, view: View): + return self.build_forms_for_view(rules, view, op=["invokeaction"]) + def property(self, rules: list, view: View): key = snake_to_camel(view.endpoint) self.properties[key] = self.view_to_thing_property(rules, view) @@ -156,3 +180,16 @@ def property(self, rules: list, view: View): def action(self, rules: list, view: View): key = snake_to_camel(view.endpoint) self.actions[key] = self.view_to_thing_action(rules, view) + + def build_forms_for_view(self, rules: list, view: View, op: list): + forms = [] + prop_urls = [rule_to_path(rule) for rule in rules] + + content_type = ( + get_topmost_spec_attr(view, "_content_type") or "application/json" + ) + + for url in prop_urls: + forms.append({"op": op, "href": url, "contentType": content_type}) + + return forms diff --git a/labthings/server/spec/utilities.py b/labthings/server/spec/utilities.py index fc42dd11..8e1d0186 100644 --- a/labthings/server/spec/utilities.py +++ b/labthings/server/spec/utilities.py @@ -32,10 +32,35 @@ def get_spec(obj): Returns: dict: API spec dictionary. Returns empty dictionary if no spec is found. """ + if not obj: + return {} obj.__apispec__ = obj.__dict__.get("__apispec__", {}) return obj.__apispec__ or {} +def get_topmost_spec_attr(view, spec_key: str): + """ + Get the __apispec__ value corresponding to spec_key, from first the root view, + falling back to GET, POST, and PUT in that descending order of priority + + Args: + obj: Python object + + Returns: + spec value corresponding to spec_key + """ + spec = get_spec(view) + value = spec.get(spec_key) + + if not value: + for meth in ["get", "post", "put"]: + spec = get_spec(getattr(view, meth, None)) + value = spec.get(spec_key) + if value: + break + return value + + def convert_schema(schema, spec: APISpec): """ Ensure that a given schema is either a real Marshmallow schema, diff --git a/labthings/server/wsgi/eventlet.py b/labthings/server/wsgi/eventlet.py deleted file mode 100644 index 53df0f15..00000000 --- a/labthings/server/wsgi/eventlet.py +++ /dev/null @@ -1,38 +0,0 @@ -import eventlet.wsgi -import eventlet -import logging -import sys -import os -import signal -from werkzeug.debug import DebuggedApplication - - -class Server: - def __init__(self, app): - self.app = app - - def run(self, host="0.0.0.0", port=5000, log=None, debug=False, stop_timeout=1): - # Type checks - port = int(port) - host = str(host) - - # Unmodified version of app - app_to_run = self.app - - # Handle logging - if not log: - log = logging.getLogger() - - friendlyhost = "localhost" if host == "0.0.0.0" else host - logging.info("Starting LabThings WSGI Server") - logging.info(f"Debug mode: {debug}") - logging.info(f"Running on http://{friendlyhost}:{port} (Press CTRL+C to quit)") - - # Create WSGIServer - addresses = eventlet.green.socket.getaddrinfo(host, port) - eventlet_socket = eventlet.listen(addresses[0][4], addresses[0][0]) - - try: - eventlet.wsgi.server(eventlet_socket, app_to_run, log=log, debug=debug) - except (KeyboardInterrupt, SystemExit): - logging.warning("Terminating by KeyboardInterrupt or SystemExit") diff --git a/labthings/server/wsgi/gevent.py b/labthings/server/wsgi/gevent.py index fbe6f20a..e0c4aa02 100644 --- a/labthings/server/wsgi/gevent.py +++ b/labthings/server/wsgi/gevent.py @@ -1,17 +1,32 @@ from geventwebsocket.handler import WebSocketHandler import gevent +import socket import logging import sys import os import signal from werkzeug.debug import DebuggedApplication +from zeroconf import IPVersion, ServiceInfo, Zeroconf, get_all_addresses + +from ..find import current_labthing + class Server: def __init__(self, app): self.app = app + # Find LabThing attached to app + self.labthing = current_labthing(app) - def run(self, host="0.0.0.0", port=5000, log=None, debug=False, stop_timeout=1): + def run( + self, + host="0.0.0.0", + port=5000, + log=None, + debug=False, + stop_timeout=1, + zeroconf=True, + ): # Type checks port = int(port) host = str(host) @@ -19,6 +34,30 @@ def run(self, host="0.0.0.0", port=5000, log=None, debug=False, stop_timeout=1): # Unmodified version of app app_to_run = self.app + # Handle zeroconf + zeroconf_server = None + if zeroconf and self.labthing: + service_info = ServiceInfo( + "_labthings._tcp.local.", + f"{self.labthing.title}._labthings._tcp.local.", + port=port, + properties={ + "path": self.labthing.url_prefix, + "title": self.labthing.title, + "description": self.labthing.description, + "types": ";".join(self.labthing.types), + }, + addresses=set( + [ + socket.inet_aton(i) + for i in get_all_addresses() + if i not in ("127.0.0.1", "0.0.0.0") + ] + ), + ) + zeroconf_server = Zeroconf(ip_version=IPVersion.V4Only) + zeroconf_server.register_service(service_info) + # Handle logging if not log: log = logging.getLogger() @@ -27,7 +66,9 @@ def run(self, host="0.0.0.0", port=5000, log=None, debug=False, stop_timeout=1): if debug: log.setLevel(logging.DEBUG) app_to_run = DebuggedApplication(self.app) + logging.getLogger("zeroconf").setLevel(logging.DEBUG) + # Slightly more useful logger output friendlyhost = "localhost" if host == "0.0.0.0" else host logging.info("Starting LabThings WSGI Server") logging.info(f"Debug mode: {debug}") @@ -39,6 +80,11 @@ def run(self, host="0.0.0.0", port=5000, log=None, debug=False, stop_timeout=1): ) def stop(): + # Unregister zeroconf service + if zeroconf_server: + zeroconf_server.unregister_service(service_info) + zeroconf_server.close() + # Stop WSGI server with timeout wsgi_server.stop(timeout=stop_timeout) # Serve diff --git a/poetry.lock b/poetry.lock index b7ce05f7..951a6aad 100644 --- a/poetry.lock +++ b/poetry.lock @@ -163,6 +163,14 @@ optional = false python-versions = "*" version = "0.4.15" +[[package]] +category = "main" +description = "Enumerates all IP addresses on all network adapters of the system." +name = "ifaddr" +optional = false +python-versions = "*" +version = "0.1.6" + [[package]] category = "dev" description = "Read metadata from Python packages" @@ -397,6 +405,17 @@ version = "1.0.0" dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] watchdog = ["watchdog"] +[[package]] +category = "main" +description = "Pure Python Multicast DNS Service Discovery Library (Bonjour/Avahi compatible)" +name = "zeroconf" +optional = false +python-versions = "*" +version = "0.24.5" + +[package.dependencies] +ifaddr = "*" + [[package]] category = "dev" description = "Backport of pathlib-compatible object wrapper for zip files" @@ -411,7 +430,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "450723557fea770ed1fb70140bda3d6de4b3d44896d92ac1f910c2154d492fcf" +content-hash = "cd172c72eed4e2c7386562fdae39220a65aa224edc1325487815d1bbb2e30010" python-versions = "^3.6" [metadata.files] @@ -534,6 +553,9 @@ greenlet = [ {file = "greenlet-0.4.15-cp38-cp38-win_amd64.whl", hash = "sha256:7457d685158522df483196b16ec648b28f8e847861adb01a55d41134e7734122"}, {file = "greenlet-0.4.15.tar.gz", hash = "sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc"}, ] +ifaddr = [ + {file = "ifaddr-0.1.6.tar.gz", hash = "sha256:c19c64882a7ad51a394451dabcbbed72e98b5625ec1e79789924d5ea3e3ecb93"}, +] importlib-metadata = [ {file = "importlib_metadata-1.5.0-py2.py3-none-any.whl", hash = "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"}, {file = "importlib_metadata-1.5.0.tar.gz", hash = "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302"}, @@ -574,11 +596,6 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] marshmallow = [ @@ -715,6 +732,10 @@ werkzeug = [ {file = "Werkzeug-1.0.0-py2.py3-none-any.whl", hash = "sha256:6dc65cf9091cf750012f56f2cad759fa9e879f511b5ff8685e456b4e3bf90d16"}, {file = "Werkzeug-1.0.0.tar.gz", hash = "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096"}, ] +zeroconf = [ + {file = "zeroconf-0.24.5-py3-none-any.whl", hash = "sha256:83c4f611338096cafea46509d08e26891800b75abdead43d13bb13094c459187"}, + {file = "zeroconf-0.24.5.tar.gz", hash = "sha256:893a841445663e0c4c20d1111ce41484bd62d58f59d653d0485187343368ef4a"}, +] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, diff --git a/pyproject.toml b/pyproject.toml index fc82c801..84d73c7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ apispec = "^3.2.0" flask-cors = "^3.0.8" gevent = "^1.4.0" gevent-websocket = "^0.10.1" +zeroconf = "^0.24.5" [tool.poetry.dev-dependencies] pytest = "^5.2"