diff --git a/CHANGELOG.md b/CHANGELOG.md index f09e1cf..6f66406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [unreleased] + +### Added +- Support for generating [NodeInfo](http://nodeinfo.diaspora.software) documents using the generator `federation.hostmeta.generators.NodeInfo`. Strict validation is skipped by default, but can be enabled by passing in `raise_on_validate` to the `NodeInfo` class. By default a warning will be generated on documents that don't conform with the strict NodeInfo values. This can be disabled by passing in `skip_validate` to the class. + ## [0.2.0] - 2016-04-09 ### Backwards incompatible changes diff --git a/federation/hostmeta/generators.py b/federation/hostmeta/generators.py index 465a01f..52bb041 100644 --- a/federation/hostmeta/generators.py +++ b/federation/hostmeta/generators.py @@ -1,8 +1,12 @@ +# -*- coding: utf-8 -*- +import warnings + from base64 import b64encode import json import os from string import Template from jsonschema import validate +from jsonschema.exceptions import ValidationError from xrd import XRD, Link, Element @@ -214,3 +218,47 @@ class SocialRelayWellKnown(object): with open(schema_path) as f: schema = json.load(f) validate(self.doc, schema) + + +class NodeInfo(object): + """Generate a NodeInfo document. + + See spec: http://nodeinfo.diaspora.software + + NodeInfo is unnecessarely restrictive in field values. We wont be supporting such strictness, though + we will raise a warning unless validation is skipped with `skip_validate=True`. + + For strictness, `raise_on_validate=True` will cause a `ValidationError` to be raised. + + See schema document `federation/hostmeta/schemas/nodeinfo-1.0.json` for how to instantiate this class. + """ + + def __init__(self, software, protocols, services, open_registrations, usage, metadata, skip_validate=False, + raise_on_validate=False): + self.doc = { + "version": "1.0", + "software": software, + "protocols": protocols, + "services": services, + "openRegistrations": open_registrations, + "usage": usage, + "metadata": metadata, + } + self.skip_validate = skip_validate + self.raise_on_validate = raise_on_validate + + def render(self): + if not self.skip_validate: + self.validate_doc() + return json.dumps(self.doc) + + def validate_doc(self): + schema_path = os.path.join(os.path.dirname(__file__), "schemas", "nodeinfo-1.0.json") + with open(schema_path) as f: + schema = json.load(f) + try: + validate(self.doc, schema) + except ValidationError: + if self.raise_on_validate: + raise + warnings.warn("NodeInfo document generated does not validate against NodeInfo 1.0 specification.") diff --git a/federation/hostmeta/schemas/nodeinfo-1.0.json b/federation/hostmeta/schemas/nodeinfo-1.0.json new file mode 100644 index 0000000..8d17755 --- /dev/null +++ b/federation/hostmeta/schemas/nodeinfo-1.0.json @@ -0,0 +1,206 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://nodeinfo.diaspora.software/ns/schema/1.0#", + "description": "NodeInfo schema version 1.0.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "software", + "protocols", + "services", + "openRegistrations", + "usage", + "metadata" + ], + "properties": { + "version": { + "description": "The schema version, must be 1.0.", + "enum": [ + "1.0" + ] + }, + "software": { + "description": "Metadata about server software in use.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "description": "The canonical name of this server software.", + "enum": [ + "diaspora", + "friendica", + "redmatrix" + ] + }, + "version": { + "description": "The version of this server software.", + "type": "string" + } + } + }, + "protocols": { + "description": "The protocols supported on this server.", + "type": "object", + "additionalProperties": false, + "required": [ + "inbound", + "outbound" + ], + "properties": { + "inbound": { + "description": "The protocols this server can receive traffic for.", + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "buddycloud", + "diaspora", + "friendica", + "gnusocial", + "libertree", + "mediagoblin", + "pumpio", + "redmatrix", + "smtp", + "tent" + ] + } + }, + "outbound": { + "description": "The protocols this server can generate traffic for.", + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "buddycloud", + "diaspora", + "friendica", + "gnusocial", + "libertree", + "mediagoblin", + "pumpio", + "redmatrix", + "smtp", + "tent" + ] + } + } + } + }, + "services": { + "description": "The third party sites this server can connect to via their application API.", + "type": "object", + "additionalProperties": false, + "required": [ + "inbound", + "outbound" + ], + "properties": { + "inbound": { + "description": "The third party sites this server can retrieve messages from for combined display with regular traffic.", + "type": "array", + "minItems": 0, + "items": { + "enum": [ + "appnet", + "gnusocial", + "pumpio" + ] + } + }, + "outbound": { + "description": "The third party sites this server can publish messages to on the behalf of a user.", + "type": "array", + "minItems": 0, + "items": { + "enum": [ + "appnet", + "blogger", + "buddycloud", + "diaspora", + "dreamwidth", + "drupal", + "facebook", + "friendica", + "gnusocial", + "google", + "insanejournal", + "libertree", + "linkedin", + "livejournal", + "mediagoblin", + "myspace", + "pinterest", + "posterous", + "pumpio", + "redmatrix", + "smtp", + "tent", + "tumblr", + "twitter", + "wordpress", + "xmpp" + ] + } + } + } + }, + "openRegistrations": { + "description": "Whether this server allows open self-registration.", + "type": "boolean" + }, + "usage": { + "description": "Usage statistics for this server.", + "type": "object", + "additionalProperties": false, + "required": [ + "users" + ], + "properties": { + "users": { + "description": "statistics about the users of this server.", + "type": "object", + "additionalProperties": false, + "properties": { + "total": { + "description": "The total amount of on this server registered users.", + "type": "integer", + "minimum": 0 + }, + "activeHalfyear": { + "description": "The amount of users that signed in at least once in the last 180 days.", + "type": "integer", + "minimum": 0 + }, + "activeMonth": { + "description": "The amount of users that signed in at least once in the last 30 days.", + "type": "integer", + "minimum": 0 + } + } + }, + "localPosts": { + "description": "The amount of posts that were made by users that are registered on this server.", + "type": "integer", + "minimum": 0 + }, + "localComments": { + "description": "The amount of comments that were made by users that are registered on this server.", + "type": "integer", + "minimum": 0 + } + } + }, + "metadata": { + "description": "Free form key value pairs for software specific values. Clients should not rely on any specific key present.", + "type": "object", + "minProperties": 0, + "additionalProperties": true + } + } +} diff --git a/federation/tests/hostmeta/test_generators.py b/federation/tests/hostmeta/test_generators.py index 9d9a171..25fd894 100644 --- a/federation/tests/hostmeta/test_generators.py +++ b/federation/tests/hostmeta/test_generators.py @@ -4,7 +4,7 @@ from jsonschema import validate, ValidationError import pytest from federation.hostmeta.generators import generate_host_meta, generate_legacy_webfinger, generate_hcard, \ - SocialRelayWellKnown + SocialRelayWellKnown, NodeInfo DIASPORA_HOSTMETA = """ @@ -138,3 +138,54 @@ class TestSocialRelayWellKnownGenerator(object): well_known = SocialRelayWellKnown(subscribe=True, tags=("foo", "bar"), scope="cities") with pytest.raises(ValidationError): well_known.render() + + +class TestNodeInfoGenerator(object): + def _valid_nodeinfo(self, raise_on_validate=False): + return NodeInfo( + software={"name": "diaspora", "version": "0.5.4.3"}, + protocols={"inbound": ["diaspora"], "outbound": ["diaspora"]}, + services={"inbound": ["pumpio"], "outbound": ["twitter"]}, + open_registrations=True, + usage={"users": {}}, + metadata={}, + raise_on_validate=raise_on_validate + ) + + def _invalid_nodeinfo(self, raise_on_validate=False): + return NodeInfo( + software={"name": "diaspora", "version": "0.5.4.3", "what_is_this_evil_key_here": True}, + protocols={"inbound": ["diaspora"], "outbound": ["diaspora"]}, + services={"inbound": ["pumpio"], "outbound": ["twitter"]}, + open_registrations=True, + usage={"users": {}}, + metadata={}, + raise_on_validate=raise_on_validate + ) + + def test_nodeinfo_generator(self): + nodeinfo = self._valid_nodeinfo() + assert nodeinfo.doc["version"] == "1.0" + assert nodeinfo.doc["software"] == {"name": "diaspora", "version": "0.5.4.3"} + assert nodeinfo.doc["protocols"] == {"inbound": ["diaspora"], "outbound": ["diaspora"]} + assert nodeinfo.doc["services"] == {"inbound": ["pumpio"], "outbound": ["twitter"]} + assert nodeinfo.doc["openRegistrations"] == True + assert nodeinfo.doc["usage"] == {"users": {}} + assert nodeinfo.doc["metadata"] == {} + + def test_nodeinfo_generator_raises_on_invalid_nodeinfo_and_raise_on_validate(self): + nodeinfo = self._invalid_nodeinfo(raise_on_validate=True) + with pytest.raises(ValidationError): + nodeinfo.render() + + def test_nodeinfo_generator_does_not_raise_on_invalid_nodeinfo(self): + nodeinfo = self._invalid_nodeinfo() + nodeinfo.render() + + def test_nodeinfo_generator_does_not_raise_on_valid_nodeinfo_and_raise_on_validate(self): + nodeinfo = self._valid_nodeinfo(raise_on_validate=True) + nodeinfo.render() + + def test_nodeinfo_generator_render_returns_a_document(self): + nodeinfo = self._valid_nodeinfo() + assert isinstance(nodeinfo.render(), str)