diff --git a/moniker/api/v1/domains.py b/moniker/api/v1/domains.py index c5f63243a..f617809b4 100644 --- a/moniker/api/v1/domains.py +++ b/moniker/api/v1/domains.py @@ -15,32 +15,25 @@ # under the License. import flask from moniker.openstack.common import log as logging +from moniker.openstack.common import jsonutils as json from moniker import exceptions -from moniker.api.v1.schemas import domain_schema, domains_schema +from moniker import schema from moniker.central import api as central_api - LOG = logging.getLogger(__name__) blueprint = flask.Blueprint('domains', __name__) - - -def _append_domain_links(values, domain_id): - values['self'] = flask.url_for('.get_domain', domain_id=domain_id) - values['records'] = flask.url_for('records.get_records', - domain_id=domain_id) - values['schema'] = flask.url_for('.get_domain_schema') - - return values +domain_schema = schema.Schema('v1', 'domain') +domains_schema = schema.Schema('v1', 'domains') @blueprint.route('/schemas/domain', methods=['GET']) def get_domain_schema(): - return flask.jsonify(domain_schema.raw()) + return flask.jsonify(domain_schema.raw) @blueprint.route('/schemas/domains', methods=['GET']) def get_domains_schema(): - return flask.jsonify(domains_schema.raw()) + return flask.jsonify(domains_schema.raw) @blueprint.route('/domains', methods=['POST']) @@ -55,12 +48,11 @@ def create_domain(): except exceptions.Forbidden: return flask.Response(status=401) except exceptions.InvalidObject, e: - return flask.Response(status=400, response=str(e)) + response_body = json.dumps({'errors': e.errors}) + return flask.Response(status=400, response=response_body) except exceptions.DuplicateDomain: return flask.Response(status=409) else: - domain = _append_domain_links(domain, domain['id']) - domain = domain_schema.filter(domain) response = flask.jsonify(domain) @@ -79,9 +71,9 @@ def get_domains(): except exceptions.Forbidden: return flask.Response(status=401) - domains = domains_schema.filter(domains) + domains = domains_schema.filter({'domains': domains}) - return flask.jsonify(domains=domains) + return flask.jsonify(domains) @blueprint.route('/domains/', methods=['GET']) @@ -95,8 +87,6 @@ def get_domain(domain_id): except exceptions.DomainNotFound: return flask.Response(status=404) else: - domain = _append_domain_links(domain, domain['id']) - domain = domain_schema.filter(domain) return flask.jsonify(domain) @@ -113,14 +103,13 @@ def update_domain(domain_id): except exceptions.Forbidden: return flask.Response(status=401) except exceptions.InvalidObject, e: - return flask.Response(status=400, response=str(e)) + response_body = json.dumps({'errors': e.errors}) + return flask.Response(status=400, response=response_body) except exceptions.DomainNotFound: return flask.Response(status=404) except exceptions.DuplicateDomain: return flask.Response(status=409) else: - domain = _append_domain_links(domain, domain['id']) - domain = domain_schema.filter(domain) return flask.jsonify(domain) diff --git a/moniker/api/v1/records.py b/moniker/api/v1/records.py index 27480c711..1bd759a0e 100644 --- a/moniker/api/v1/records.py +++ b/moniker/api/v1/records.py @@ -15,32 +15,25 @@ # under the License. import flask from moniker.openstack.common import log as logging +from moniker.openstack.common import jsonutils as json from moniker import exceptions -from moniker.api.v1.schemas import record_schema, records_schema +from moniker import schema from moniker.central import api as central_api - LOG = logging.getLogger(__name__) blueprint = flask.Blueprint('records', __name__) - - -def _append_record_links(values, domain_id, record_id): - values['self'] = flask.url_for('.get_record', domain_id=domain_id, - record_id=record_id) - values['domain'] = flask.url_for('domains.get_domain', domain_id=domain_id) - values['schema'] = flask.url_for('.get_record_schema') - - return values +record_schema = schema.Schema('v1', 'record') +records_schema = schema.Schema('v1', 'records') @blueprint.route('/schemas/record', methods=['GET']) def get_record_schema(): - return flask.jsonify(record_schema.raw()) + return flask.jsonify(record_schema.raw) @blueprint.route('/schemas/records', methods=['GET']) def get_records_schema(): - return flask.jsonify(records_schema.raw()) + return flask.jsonify(records_schema.raw) @blueprint.route('/domains//records', methods=['POST']) @@ -54,12 +47,11 @@ def create_record(domain_id): except exceptions.Forbidden: return flask.Response(status=401) except exceptions.InvalidObject, e: - return flask.Response(status=400, response=str(e)) + response_body = json.dumps({'errors': e.errors}) + return flask.Response(status=400, response=response_body) except exceptions.DuplicateRecord: return flask.Response(status=409) else: - record = _append_record_links(record, record['domain_id'], - record['id']) record = record_schema.filter(record) response = flask.jsonify(record) @@ -79,7 +71,9 @@ def get_records(domain_id): except exceptions.Forbidden: return flask.Response(status=401) - return flask.jsonify(records=records) + records = records_schema.filter({'records': records}) + + return flask.jsonify(records) @blueprint.route('/domains//records/', methods=['GET']) @@ -93,8 +87,6 @@ def get_record(domain_id, record_id): except exceptions.RecordNotFound: return flask.Response(status=404) else: - record = _append_record_links(record, record['domain_id'], - record['id']) record = record_schema.filter(record) return flask.jsonify(record) @@ -112,14 +104,13 @@ def update_record(domain_id, record_id): except exceptions.Forbidden: return flask.Response(status=401) except exceptions.InvalidObject, e: - return flask.Response(status=400, response=str(e)) + response_body = json.dumps({'errors': e.errors}) + return flask.Response(status=400, response=response_body) except exceptions.RecordNotFound: return flask.Response(status=404) except exceptions.DuplicateRecord: return flask.Response(status=409) else: - record = _append_record_links(record, record['domain_id'], - record['id']) record = record_schema.filter(record) return flask.jsonify(record) diff --git a/moniker/api/v1/schemas.py b/moniker/api/v1/schemas.py deleted file mode 100644 index 6d4cdcd81..000000000 --- a/moniker/api/v1/schemas.py +++ /dev/null @@ -1,154 +0,0 @@ -from moniker.schema import Schema, CollectionSchema - -SERVER_PROPERTIES = { - 'id': { - 'type': 'string', - 'description': 'Server identifier', - 'pattern': ('^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}' - '-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$'), - }, - 'name': { - 'type': 'string', - 'description': 'Server DNS name', - 'maxLength': 255, - 'pattern': '^.+[^\\.]$', # TODO: Figure out the correct regex - 'required': True, - }, - 'ipv4': { - 'type': 'string', - 'description': 'IPv4 address of server', - 'format': 'ip-address', - 'required': True, - }, - 'ipv6': { - 'type': 'string', - 'description': 'IPv6 address of server', - 'format': 'ipv6', - }, - 'created_at': { - 'type': 'string', - 'description': 'Date and time of server creation', - 'format': 'date-time', - }, - 'updated_at': { - 'type': 'string', - 'description': 'Date and time of last server update', - 'format': 'date-time', - }, - 'self': {'type': 'string'}, - 'schema': {'type': 'string'}, -} - -SERVER_LINKS = [ - {'rel': 'self', 'href': '{self}'}, - {'rel': 'describedby', 'href': '{schema}'}, -] - -DOMAIN_PROPERTIES = { - 'id': { - 'type': 'string', - 'description': 'Domain identifier', - 'pattern': ('^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}' - '-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$'), - }, - 'name': { - 'type': 'string', - 'description': 'Domain name', - 'maxLength': 255, - 'pattern': '^.+[^\\.]$', # TODO: Figure out the correct regex - 'required': True, - }, - 'email': { - 'type': 'string', - 'description': 'Hostmaster email address', - 'maxLength': 255, - 'required': True, - }, - 'ttl': { - 'type': 'integer', - 'description': 'Time to live', - }, - 'serial': { - 'type': 'integer', - 'description': 'Serial Number', - }, - 'created_at': { - 'type': 'string', - 'description': 'Date and time of image registration', - 'format': 'date-time', - }, - 'updated_at': { - 'type': 'string', - 'description': 'Date and time of image registration', - 'format': 'date-time', - }, - 'self': {'type': 'string'}, - 'records': {'type': 'string'}, - 'schema': {'type': 'string'}, -} - -DOMAIN_LINKS = [ - {'rel': 'self', 'href': '{self}'}, - {'rel': 'records', 'href': '{records}', 'method': 'GET'}, - {'rel': 'describedby', 'href': '{schema}', 'method': 'GET'}, -] - -RECORD_PROPERTIES = { - 'id': { - 'type': 'string', - 'description': 'Record identifier', - 'pattern': ('^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}' - '-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$'), - }, - 'name': { - 'type': 'string', - 'description': 'DNS Record Name', - 'maxLength': 255, - 'pattern': '^.+[^\\.]$', # TODO: Figure out the correct regex - 'required': True, - }, - 'type': { - 'type': 'string', - 'description': 'DNS Record Type', - 'enum': ['A', 'AAAA', 'CNAME', 'MX', 'SRV', 'TXT', 'SPF', 'NS'], - }, - 'data': { - 'type': 'string', - 'description': 'DNS Record Value', - 'maxLength': 255, - 'required': True, - }, - 'ttl': { - 'type': 'integer', - 'description': 'Time to live.', - 'min': 60, # TODO: This should be a config option - }, - 'created_at': { - 'type': 'string', - 'description': 'Date and time of image registration', - 'format': 'date-time', - }, - 'updated_at': { - 'type': 'string', - 'description': 'Date and time of image registration', - 'format': 'date-time', - }, - 'self': {'type': 'string'}, - 'domain': {'type': 'string'}, - 'schema': {'type': 'string'}, -} - -RECORD_LINKS = [ - {'rel': 'self', 'href': '{self}'}, - {'rel': 'domain', 'href': '{domain}'}, - {'rel': 'describedby', 'href': '{schema}'}, -] - -server_schema = Schema('server', SERVER_PROPERTIES, SERVER_LINKS) -servers_schema = CollectionSchema('servers', server_schema) - -domain_schema = Schema('domain', DOMAIN_PROPERTIES, DOMAIN_LINKS) -domains_schema = CollectionSchema('domains', domain_schema) - -record_schema = Schema('record', RECORD_PROPERTIES, RECORD_LINKS) -records_schema = CollectionSchema('records', record_schema) diff --git a/moniker/api/v1/servers.py b/moniker/api/v1/servers.py index c58d4601a..98377c0ca 100644 --- a/moniker/api/v1/servers.py +++ b/moniker/api/v1/servers.py @@ -15,30 +15,25 @@ # under the License. import flask from moniker.openstack.common import log as logging +from moniker.openstack.common import jsonutils as json from moniker import exceptions -from moniker.api.v1.schemas import server_schema, servers_schema +from moniker import schema from moniker.central import api as central_api - LOG = logging.getLogger(__name__) blueprint = flask.Blueprint('servers', __name__) - - -def _append_server_links(values, server_id): - values['self'] = flask.url_for('.get_server', server_id=server_id) - values['schema'] = flask.url_for('.get_server_schema') - - return values +server_schema = schema.Schema('v1', 'server') +servers_schema = schema.Schema('v1', 'servers') @blueprint.route('/schemas/server', methods=['GET']) def get_server_schema(): - return flask.jsonify(server_schema.raw()) + return flask.jsonify(server_schema.raw) @blueprint.route('/schemas/servers', methods=['GET']) def get_servers_schema(): - return flask.jsonify(servers_schema.raw()) + return flask.jsonify(servers_schema.raw) @blueprint.route('/servers', methods=['POST']) @@ -52,12 +47,11 @@ def create_server(): except exceptions.Forbidden: return flask.Response(status=401) except exceptions.InvalidObject, e: - return flask.Response(status=400, response=str(e)) + response_body = json.dumps({'errors': e.errors}) + return flask.Response(status=400, response=response_body) except exceptions.DuplicateServer: return flask.Response(status=409) else: - server = _append_server_links(server, server['id']) - server = server_schema.filter(server) response = flask.jsonify(server) @@ -76,9 +70,9 @@ def get_servers(): except exceptions.Forbidden: return flask.Response(status=401) - servers = servers_schema.filter(servers) + servers = servers_schema.filter({'servers': servers}) - return flask.jsonify(servers=servers) + return flask.jsonify(servers) @blueprint.route('/servers/', methods=['GET']) @@ -92,8 +86,6 @@ def get_server(server_id): except exceptions.ServerNotFound: return flask.Response(status=404) else: - server = _append_server_links(server, server['id']) - server = server_schema.filter(server) return flask.jsonify(server) @@ -110,14 +102,13 @@ def update_server(server_id): except exceptions.Forbidden: return flask.Response(status=401) except exceptions.InvalidObject, e: - return flask.Response(status=400, response=str(e)) + response_body = json.dumps({'errors': e.errors}) + return flask.Response(status=400, response=response_body) except exceptions.ServerNotFound: return flask.Response(status=404) except exceptions.DuplicateServer: return flask.Response(status=409) else: - server = _append_server_links(server, server['id']) - server = server_schema.filter(server) return flask.jsonify(server) diff --git a/moniker/exceptions.py b/moniker/exceptions.py index 39d1c04f4..92dbce46d 100644 --- a/moniker/exceptions.py +++ b/moniker/exceptions.py @@ -32,7 +32,9 @@ class NoServersConfigured(ConfigurationError): class InvalidObject(Base): - pass + def __init__(self, *args, **kwargs): + self.errors = kwargs.pop('errors', None) + super(InvalidObject, self).__init__(*args, **kwargs) class Forbidden(Base): diff --git a/moniker/manage/database.py b/moniker/manage/database.py index 2a4b88a16..ce475b38f 100644 --- a/moniker/manage/database.py +++ b/moniker/manage/database.py @@ -33,7 +33,7 @@ class InitCommand(Command): "Init database" def take_action(self, parsed_args): - utils.read_config('moniker-central') + utils.read_config('moniker-central', []) url = cfg.CONF.database_connection @@ -53,7 +53,7 @@ class SyncCommand(Command): def take_action(self, parsed_args): # TODO: Support specifying version - utils.read_config('moniker-central') + utils.read_config('moniker-central', []) url = cfg.CONF.database_connection diff --git a/moniker/resources/schemas/v1/domain.json b/moniker/resources/schemas/v1/domain.json new file mode 100644 index 000000000..0eecc9b74 --- /dev/null +++ b/moniker/resources/schemas/v1/domain.json @@ -0,0 +1,62 @@ +{ + "id": "/schemas/domain", + + "$schema": "http://json-schema.org/draft-03/hyper-schema", + + "title": "domain", + "description": "Domain", + + "properties": { + "id": { + "type": "string", + "description": "Domain Identifier", + "pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$", + "readonly": true + }, + "name": { + "type": "string", + "description": "Domain name", + "format": "host-name", + "maxLength": 255, + "required": true + }, + "email": { + "type": "string", + "description": "Hostmaster email address", + "format": "email", + "maxLength": 255, + "required": true + }, + "ttl": { + "type": "integer", + "description": "Time to live" + }, + "serial": { + "type": "integer", + "description": "Serial Number", + "readonly": true + }, + "created_at": { + "type": "string", + "description": "Date and time of image registration", + "format": "date-time", + "readonly": true + }, + "updated_at": { + "type": ["string", "null"], + "description": "Date and time of image registration", + "format": "date-time", + "readonly": true + } + }, + "links": [{ + "rel": "self", + "href": "/domains/{id}" + }, { + "rel": "records", + "href": "/domains/{id}/records" + }, { + "rel": "collection", + "href": "/domains" + }] +} diff --git a/moniker/resources/schemas/v1/domains.json b/moniker/resources/schemas/v1/domains.json new file mode 100644 index 000000000..341edb376 --- /dev/null +++ b/moniker/resources/schemas/v1/domains.json @@ -0,0 +1,16 @@ +{ + "id": "/schemas/domains", + + "$schema": "http://json-schema.org/draft-03/hyper-schema", + + "title": "domains", + "description": "Domains", + + "properties": { + "domains": { + "type": "array", + "description": "Domains", + "items": {"$ref": "/schemas/domain"} + } + } +} diff --git a/moniker/resources/schemas/v1/record.json b/moniker/resources/schemas/v1/record.json new file mode 100644 index 000000000..80a16dc77 --- /dev/null +++ b/moniker/resources/schemas/v1/record.json @@ -0,0 +1,186 @@ +{ + "id": "/schemas/record", + + "$schema": "http://json-schema.org/draft-03/hyper-schema", + + "title": "record", + "description": "Record", + + "properties": { + "id": { + "type": "string", + "description": "Record Identifier", + "pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$", + "readonly": true + }, + "domain_id": { + "type": "string", + "description": "Domain Identifier", + "pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$", + "readonly": true + }, + "name": { + "type": "string", + "description": "DNS Record Name", + "format": "host-name", + "maxLength": 255, + "required": true + }, + "type": { + "type": "string", + "description": "DNS Record Type", + "enum": ["A", "AAAA", "CNAME", "MX", "SRV", "TXT", "SPF", "NS"], + "required": true + }, + "data": { + "type": "string", + "description": "DNS Record Value", + "maxLength": 255, + "required": true + }, + "priority": { + "type": ["integer", "null"], + "description": "DNS Record Priority", + "min": 0, + "max": 99999 + }, + "ttl": { + "type": ["integer", "null"], + "description": "Time to live", + "min": 60 + }, + "created_at": { + "type": "string", + "description": "Date and time of image registration", + "format": "date-time", + "readonly": true + }, + "updated_at": { + "type": ["string", "null"], + "description": "Date and time of image registration", + "format": "date-time", + "readonly": true + } + }, + "oneOf": [{ + "description": "An A Record", + "properties": { + "type": { + "type": "string", + "enum": ["A"] + }, + "data": { + "format": "ip-address", + "required": true + }, + "priority": { + "type": "null" + } + } + }, { + "description": "An AAAA Record", + "properties": { + "type": { + "type": "string", + "enum": ["AAAA"] + }, + "data": { + "format": "ipv6", + "required": true + }, + "priority": { + "type": "null" + } + } + }, { + "description": "A CNAME Record", + "properties": { + "type": { + "type": "string", + "enum": ["CNAME"] + }, + "data": { + "format": "host-name", + "required": true + }, + "priority": { + "type": "null" + } + } + }, { + "description": "A MX Record", + "properties": { + "type": { + "type": "string", + "enum": ["MX"] + }, + "data": { + "format": "host-name", + "required": true + }, + "priority": { + "type": "integer", + "required": true + } + } + }, { + "description": "A SRV Record", + "properties": { + "type": { + "type": "string", + "enum": ["SRV"] + }, + "priority": { + "type": "integer", + "required": true + } + } + }, { + "description": "A TXT Record", + "properties": { + "type": { + "type": "string", + "enum": ["TXT"] + }, + "priority": { + "type": "null" + } + } + }, { + "description": "A SPF Record", + "properties": { + "type": { + "type": "string", + "enum": ["SPF"] + }, + "priority": { + "type": "null" + } + } + }, { + "description": "A NS Record", + "properties": { + "type": { + "type": "string", + "enum": ["NS"] + }, + "data": { + "format": "host-name", + "required": true + }, + "priority": { + "type": "null" + } + } + }], + "links": [{ + "rel": "self", + "href": "/domains/{domain_id}/records/{id}" + }, { + "rel": "domain", + "href": "/domains/{domain_id}" + }, { + "rel": "collection", + "href": "/domains/{domain_id}/records" + }] +} diff --git a/moniker/resources/schemas/v1/records.json b/moniker/resources/schemas/v1/records.json new file mode 100644 index 000000000..f8fb3754f --- /dev/null +++ b/moniker/resources/schemas/v1/records.json @@ -0,0 +1,16 @@ +{ + "id": "/schemas/records", + + "$schema": "http://json-schema.org/draft-03/hyper-schema", + + "title": "records", + "description": "Records", + + "properties": { + "records": { + "type": "array", + "description": "Records", + "items": {"$ref": "/schemas/record"} + } + } +} diff --git a/moniker/resources/schemas/v1/server.json b/moniker/resources/schemas/v1/server.json new file mode 100644 index 000000000..2af9c7813 --- /dev/null +++ b/moniker/resources/schemas/v1/server.json @@ -0,0 +1,68 @@ +{ + "id": "/schemas/server", + + "$schema": "http://json-schema.org/draft-03/hyper-schema", + + "title": "server", + "description": "Server", + + "properties": { + "id": { + "type": "string", + "description": "Server Identifier", + "pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$", + "readonly": true + }, + "name": { + "type": "string", + "description": "Server DNS name", + "format": "host-name", + "maxLength": 255, + "required": true + }, + "ipv4": { + "type": "string", + "description": "IPv4 address of server", + "format": "ip-address" + }, + "ipv6": { + "type": "string", + "description": "IPv6 address of server", + "format": "ipv6" + }, + "created_at": { + "type": "string", + "description": "Date and time of server creation", + "format": "date-time", + "readonly": true + }, + "updated_at": { + "type": ["string", "null"], + "description": "Date and time of last server update", + "format": "date-time", + "readonly": true + } + }, + "anyOf": [{ + "description": "IPv4", + "properties": { + "ipv4": { + "required": true + } + } + }, { + "description": "IPv6", + "properties": { + "ipv6": { + "required": true + } + } + }], + "links": [{ + "rel": "self", + "href": "/servers/{id}" + }, { + "rel": "collection", + "href": "/servers" + }] +} diff --git a/moniker/resources/schemas/v1/servers.json b/moniker/resources/schemas/v1/servers.json new file mode 100644 index 000000000..d7b900221 --- /dev/null +++ b/moniker/resources/schemas/v1/servers.json @@ -0,0 +1,16 @@ +{ + "id": "/schemas/servers", + + "$schema": "http://json-schema.org/draft-03/hyper-schema", + + "title": "servers", + "description": "Servers", + + "properties": { + "servers": { + "type": "array", + "description": "Servers", + "items": {"$ref": "/schemas/server"} + } + } +} diff --git a/moniker/resources/templates/bind9-zone.jinja2 b/moniker/resources/templates/bind9-zone.jinja2 index 5ebb3de01..46a95c1c2 100644 --- a/moniker/resources/templates/bind9-zone.jinja2 +++ b/moniker/resources/templates/bind9-zone.jinja2 @@ -1,6 +1,6 @@ $TTL {{ domain.ttl }} -{{ domain.name }}. IN SOA {{ servers[0].name }}. {{ domain.email | replace("@", ".") }}. ( +{{ domain.name }} IN SOA {{ servers[0].name }} {{ domain.email | replace("@", ".") }}. ( {{ domain.serial }} ; serial {{ domain.refresh }} ; refresh {{ domain.retry }} ; retry @@ -14,9 +14,9 @@ $TTL {{ domain.ttl }} {% for record in records %} {% if record.type in ('NS', 'MX', 'CNAME', 'SRV') -%} -{{record.name}}. {{record.ttl or ''}} IN {{record.type}} {{record.priority or ''}} {{record.data}}. +{{record.name}} {{record.ttl or ''}} IN {{record.type}} {{record.priority or ''}} {{record.data}}. {%- else -%} -{{record.name}}. {{record.ttl or ''}} IN {{record.type}} {{record.priority or ''}} {{record.data}} +{{record.name}} {{record.ttl or ''}} IN {{record.type}} {{record.priority or ''}} {{record.data}} {%- endif %} {%- endfor %} diff --git a/moniker/schema.py b/moniker/schema.py index fabad42e1..bb3d40480 100644 --- a/moniker/schema.py +++ b/moniker/schema.py @@ -1,114 +1,214 @@ -# Copyright 2012 OpenStack LLC. -# All Rights Reserved. +# Copyright 2012 Managed I.T. # -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at +# Author: Kiall Mac Innes # -# http://www.apache.org/licenses/LICENSE-2.0 +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at # -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. +# http://www.apache.org/licenses/LICENSE-2.0 # -# NOTE(kiall): Copied from Glance +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import re import jsonschema -from moniker.openstack.common.gettextutils import _ +import ipaddr +import iso8601 +from moniker.openstack.common import log as logging from moniker import exceptions +from moniker import utils + +LOG = logging.getLogger(__name__) + +# TODO: We shouldn't hard code this list.. +resolver = jsonschema.RefResolver(store={ + '/schemas/domain': utils.load_schema('v1', 'domain'), + '/schemas/domains': utils.load_schema('v1', 'domains'), + '/schemas/record': utils.load_schema('v1', 'record'), + '/schemas/records': utils.load_schema('v1', 'records'), + '/schemas/server': utils.load_schema('v1', 'server'), + '/schemas/servers': utils.load_schema('v1', 'servers'), +}) + + +class SchemaValidator(jsonschema.Draft3Validator): + def validate_format(self, format, instance, schema): + if format == "date-time": + # ISO 8601 format + if self.is_type(instance, "string"): + try: + iso8601.parse_date(instance) + except: + msg = "%s is not an ISO 8601 date" % (instance) + yield jsonschema.ValidationError(msg) + elif format == "date": + # YYYY-MM-DD + if self.is_type(instance, "string"): + # TODO: I'm sure there is a more accurate regex than this.. + pattern = ('^[0-9]{4}-(((0[13578]|(10|12))-' + '(0[1-9]|[1-2][0-9]|3[0-1]))|' + '(02-(0[1-9]|[1-2][0-9]))|((0[469]|11)-' + '(0[1-9]|[1-2][0-9]|30)))$') + + if not re.match(pattern, instance): + msg = "%s is not a date" % (instance) + yield jsonschema.ValidationError(msg) + elif format == "time": + # hh:mm:ss + if self.is_type(instance, "string"): + # TODO: I'm sure there is a more accurate regex than this.. + pattern = "^(?:(?:([01]?\d|2[0-3]):)?([0-5]?\d):)?([0-5]?\d)$" + if not re.match(pattern, instance): + msg = "%s is not a time" % (instance) + yield jsonschema.ValidationError(msg) + pass + elif format == "email": + # A valid email address + pass + elif format == "ip-address": + # IPv4 Address + if self.is_type(instance, "string"): + try: + ipaddr.IPv4Address(instance) + except ipaddr.AddressValueError: + msg = "%s is not an IPv4 address" % (instance) + yield jsonschema.ValidationError(msg) + elif format == "ipv6": + # IPv6 Address + if self.is_type(instance, "string"): + try: + ipaddr.IPv6Address(instance) + except ipaddr.AddressValueError: + msg = "%s is not an IPv6 address" % (instance) + yield jsonschema.ValidationError(msg) + elif format == "host-name": + # A valid hostname + if self.is_type(instance, "string"): + # TODO: I'm sure there is a more accurate regex than this.. + pattern = ('^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*' + '([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])\.$') + + if not re.match(pattern, instance): + msg = "%s is not a host name" % (instance) + yield jsonschema.ValidationError(msg) + + def validate_anyOf(self, schemas, instance, schema): + for s in schemas: + if self.is_valid(instance, s): + return + else: + yield jsonschema.ValidationError( + "%r is not valid for any of listed schemas %r" % + (instance, schemas) + ) + + def validate_allOf(self, schemas, instance, schema): + for s in schemas: + if not self.is_valid(instance, s): + yield jsonschema.ValidationError( + "%r is not valid against %r" % (instance, s) + ) + + def validate_oneOf(self, schemas, instance, schema): + match = False + for s in schemas: + if self.is_valid(instance, s): + if match: + yield jsonschema.ValidationError( + "%r matches more than one schema in %r" % + (instance, schemas) + ) + match = True + if not match: + yield jsonschema.ValidationError( + "%r is not valid for any of listed schemas %r" % + (instance, schemas) + ) class Schema(object): - def __init__(self, name, properties=None, links=None): - self.name = name - if properties is None: - properties = {} - self.properties = properties - self.links = links + def __init__(self, version, name): + self.raw_schema = utils.load_schema(version, name) + self.validator = SchemaValidator(self.raw_schema, resolver=resolver) + + @property + def schema(self): + return self.validator.schema + + @property + def properties(self): + return self.schema['properties'] + + @property + def resolver(self): + return self.validator.resolver + + @property + def links(self): + return self.schema['links'] + + @property + def raw(self): + return self.raw_schema def validate(self, obj): - try: - jsonschema.validate(obj, self.raw()) - except jsonschema.ValidationError as e: - raise exceptions.InvalidObject("Provided object does not match " - "schema '%s': %s" - % (self.name, str(e))) + errors = [] + + for error in self.validator.iter_errors(obj): + errors.append({ + 'path': ".".join(reversed(error.path)), + 'message': error.message, + 'validator': error.validator + }) + + if len(errors) > 0: + raise exceptions.InvalidObject("Provided object does not match " + "schema", errors=errors) + + def filter(self, instance, properties=None): + if not properties: + properties = self.properties - def filter(self, obj): filtered = {} - for key, value in obj.iteritems(): - if self._filter_func(self.properties, key) and value is not None: - filtered[key] = value + + for name, subschema in properties.items(): + if 'type' in subschema and subschema['type'] == 'array': + subinstance = instance.get(name, None) + filtered[name] = self._filter_array(subinstance, subschema) + elif 'type' in subschema and subschema['type'] == 'object': + subinstance = instance.get(name, None) + properties = subschema['properties'] + filtered[name] = self.filter(subinstance, properties) + else: + filtered[name] = instance.get(name, None) + return filtered - @staticmethod - def _filter_func(properties, key): - return key in properties + def _filter_array(self, instance, schema): + if 'items' in schema and isinstance(schema['items'], list): + # TODO: We currently don't make use of this.. + raise NotImplementedError() - def merge_properties(self, properties): - # Ensure custom props aren't attempting to override base props - original_keys = set(self.properties.keys()) - new_keys = set(properties.keys()) - intersecting_keys = original_keys.intersection(new_keys) - conflicting_keys = [k for k in intersecting_keys - if self.properties[k] != properties[k]] - if len(conflicting_keys) > 0: - props = ', '.join(conflicting_keys) - reason = _("custom properties (%(props)s) conflict " - "with base properties") - raise exceptions.SchemaLoadError(reason=reason % {'props': props}) + elif 'items' in schema: + schema = schema['items'] - self.properties.update(properties) + if '$ref' in schema: + schema = self.resolver.resolve(self.schema, schema['$ref']) - def raw(self): - raw = { - 'name': self.name, - 'properties': self.properties, - 'additionalProperties': False, - } - if self.links: - raw['links'] = self.links - return raw + properties = schema['properties'] + return [self.filter(i, properties) for i in instance] -class PermissiveSchema(Schema): - @staticmethod - def _filter_func(properties, key): - return True + elif 'properties' in schema: + schema = schema['properties'] - def raw(self): - raw = super(PermissiveSchema, self).raw() - raw['additionalProperties'] = {'type': 'string'} - return raw + if '$ref' in schema: + schema = self.resolver.resolve(self.schema, schema['$ref']) + return [self.filter(i, schema) for i in instance] -class CollectionSchema(object): - def __init__(self, name, item_schema): - self.name = name - self.item_schema = item_schema - - def filter(self, obj): - if not obj: - return [] - - return [self.item_schema.filter(o) for o in obj] - - def raw(self): - return { - 'name': self.name, - 'properties': { - self.name: { - 'type': 'array', - 'items': self.item_schema.raw(), - }, - 'first': {'type': 'string'}, - 'next': {'type': 'string'}, - 'schema': {'type': 'string'}, - }, - 'links': [ - {'rel': 'first', 'href': '{first}'}, - {'rel': 'next', 'href': '{next}'}, - {'rel': 'describedby', 'href': '{schema}'}, - ], - } + else: + raise NotImplementedError('Can\'t filter unknown array type') diff --git a/moniker/sqlalchemy/types.py b/moniker/sqlalchemy/types.py index e4b1f4492..ddc863850 100644 --- a/moniker/sqlalchemy/types.py +++ b/moniker/sqlalchemy/types.py @@ -17,7 +17,6 @@ from sqlalchemy.types import TypeDecorator, CHAR, VARCHAR from sqlalchemy.dialects.postgresql import UUID as pgUUID from sqlalchemy.dialects.postgresql import INET as pgINET import uuid -import ipaddr class UUID(TypeDecorator): diff --git a/moniker/tests/test_schema.py b/moniker/tests/test_schema.py new file mode 100644 index 000000000..a49ebae89 --- /dev/null +++ b/moniker/tests/test_schema.py @@ -0,0 +1,24 @@ +# Copyright 2012 Managed I.T. +# +# Author: Kiall Mac Innes +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from moniker.tests import TestCase +from moniker import schema + + +class TestSchema(TestCase): + def test_constructor(self): + domain = schema.Schema('v1', 'domain') + + self.assertIsInstance(domain, schema.Schema) diff --git a/moniker/tests/test_utils.py b/moniker/tests/test_utils.py index b3dbebb33..b3a731399 100644 --- a/moniker/tests/test_utils.py +++ b/moniker/tests/test_utils.py @@ -35,6 +35,15 @@ class TestUtils(TestCase): with self.assertRaises(exceptions.ResourceNotFound): utils.resource_string(name) + def test_load_schema(self): + schema = utils.load_schema('v1', 'domain') + + self.assertIsInstance(schema, dict) + + def test_load_schema_missing(self): + with self.assertRaises(exceptions.ResourceNotFound): + utils.load_schema('v1', 'missing') + def test_load_template(self): name = 'bind9-config.jinja2' diff --git a/moniker/utils.py b/moniker/utils.py index f46978e45..8692e7886 100644 --- a/moniker/utils.py +++ b/moniker/utils.py @@ -15,6 +15,7 @@ # under the License. import os import pkg_resources +import json from jinja2 import Template from moniker.openstack.common import log as logging from moniker.openstack.common import cfg @@ -79,6 +80,12 @@ def resource_string(*args): return pkg_resources.resource_string('moniker', resource_path) +def load_schema(version, name): + schema_string = resource_string('schemas', version, '%s.json' % name) + + return json.loads(schema_string) + + def load_template(template_name): template_string = resource_string('templates', template_name) diff --git a/tools/pip-requires b/tools/pip-requires index 980a69ddc..b86a81305 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -1,6 +1,6 @@ Flask>=0.8 eventlet -jsonschema>=0.6 +https://github.com/Julian/jsonschema/archive/870869b4e6b210fac66823bd2c1ca103dc9eb9d9.tar.gz#egg=jsonschema ipaddr Paste PasteDeploy