From 85ebac7eb3c398fffc82917a1061b54e4c643494 Mon Sep 17 00:00:00 2001 From: Julien Duponchelle Date: Mon, 20 Jun 2016 18:45:31 +0200 Subject: [PATCH] API for rectangle & co Ref #498 --- docs/general.rst | 3 + docs/glossary.rst | 6 + gns3server/controller/item.py | 111 ++++++++++++++++++ gns3server/controller/project.py | 52 +++++++- gns3server/controller/topology.py | 5 +- .../handlers/api/controller/__init__.py | 1 + .../handlers/api/controller/item_handler.py | 107 +++++++++++++++++ gns3server/schemas/item.py | 59 ++++++++++ tests/controller/test_item.py | 74 ++++++++++++ tests/controller/test_project.py | 26 ++++ tests/controller/test_topology.py | 9 +- tests/handlers/api/controller/test_item.py | 87 ++++++++++++++ 12 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 gns3server/controller/item.py create mode 100644 gns3server/handlers/api/controller/item_handler.py create mode 100644 gns3server/schemas/item.py create mode 100644 tests/controller/test_item.py create mode 100644 tests/handlers/api/controller/test_item.py diff --git a/docs/general.rst b/docs/general.rst index add09715..916d5399 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -265,6 +265,9 @@ The available notification are: * link.created * link.updated * link.deleted + * item.created + * item.updated + * item.deleted * log.error * log.warning * log.info diff --git a/docs/glossary.rst b/docs/glossary.rst index 7fc014f4..ec00ed2e 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -6,6 +6,12 @@ Node A Virtual Machine (Dynamips, IOU, Qemu, VPCS...), a cloud, a builtin device (switch, hub...) +Item +----- + +Item are visual element not used by the network emulation. Like +text, images, rectangle... They are pure SVG elements. + Adapter ------- diff --git a/gns3server/controller/item.py b/gns3server/controller/item.py new file mode 100644 index 00000000..4dc50e5c --- /dev/null +++ b/gns3server/controller/item.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +# +# Copyright (C) 2016 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +import uuid + + +class Item: + """ + Item are visual element not used by the network emulation. Like + text, images, rectangle... They are pure SVG elements. + """ + def __init__(self, project, item_id=None, svg=None, x=0, y=0, z=0): + self.svg = "" + self._project = project + if item_id is None: + self._id = str(uuid.uuid4()) + else: + self._id = item_id + self._x = x + self._y = y + self._z = z + + @property + def id(self): + return self._id + + @property + def svg(self): + return self._svg + + @svg.setter + def svg(self, value): + self._svg = value + + @property + def x(self): + return self._x + + @x.setter + def x(self, val): + self._x = val + + @property + def y(self): + return self._y + + @y.setter + def y(self, val): + self._y = val + + @property + def z(self): + return self._z + + @z.setter + def z(self, val): + self._z = val + + @asyncio.coroutine + def update(self, **kwargs): + """ + Update the node on the compute server + + :param kwargs: Node properties + """ + + # Update node properties with additional elements + for prop in kwargs: + if getattr(self, prop) != kwargs[prop]: + setattr(self, prop, kwargs[prop]) + self._project.controller.notification.emit("item.updated", self.__json__()) + self._project.dump() + + def __json__(self, topology_dump=False): + """ + :param topology_dump: Filter to keep only properties require for saving on disk + """ + if topology_dump: + return { + "item_id": self._id, + "x": self._x, + "y": self._y, + "z": self._z, + } + return { + "project_id": self._project.id, + "item_id": self._id, + "x": self._x, + "y": self._y, + "z": self._z, + } + + def __repr__(self): + return "".format(self._id) + + diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 92a50c35..a9809af0 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -24,6 +24,7 @@ import shutil from uuid import UUID, uuid4 from .node import Node +from .item import Item from .topology import project_to_topology, load_topology from .udp_link import UDPLink from ..config import Config @@ -76,6 +77,7 @@ class Project: self._allocated_node_names = set() self._nodes = {} self._links = {} + self._items = {} # Create the project on demand on the compute node self._project_created_on_compute = set() @@ -263,6 +265,44 @@ class Project: """ return self._nodes + @property + def items(self): + """ + :returns: Dictionary of the items + """ + return self._items + + @asyncio.coroutine + def add_item(self, item_id=None, **kwargs): + """ + Create an item or return an existing item + + :param kwargs: See the documentation of item + """ + if item_id not in self._items: + item = Item(self, item_id=item_id, **kwargs) + self._items[item.id] = item + self.controller.notification.emit("item.created", item.__json__()) + self.dump() + return item + return self._items[item_id] + + def get_item(self, item_id): + """ + Return the Item or raise a 404 if the item is unknown + """ + try: + return self._items[item_id] + except KeyError: + raise aiohttp.web.HTTPNotFound(text="Item ID {} doesn't exist".format(item_id)) + + @asyncio.coroutine + def delete_item(self, item_id): + item = self.get_item(item_id) + del self._items[item.id] + self.dump() + self.controller.notification.emit("item.deleted", item.__json__()) + @asyncio.coroutine def add_link(self, link_id=None): """ @@ -344,18 +384,21 @@ class Project: path = self._topology_file() if os.path.exists(path): topology = load_topology(path)["topology"] - for compute in topology["computes"]: + for compute in topology.get("computes", []): yield from self.controller.add_compute(**compute) - for node in topology["nodes"]: + for node in topology.get("nodes", []): compute = self.controller.get_compute(node.pop("compute_id")) name = node.pop("name") node_id = node.pop("node_id") yield from self.add_node(compute, name, node_id, **node) - for link_data in topology["links"]: + for link_data in topology.get("links", []): link = yield from self.add_link(link_id=link_data["link_id"]) for node_link in link_data["nodes"]: node = self.get_node(node_link["node_id"]) yield from link.add_node(node, node_link["adapter_number"], node_link["port_number"]) + + for item_data in topology.get("items", []): + item = yield from self.add_item(**item_data) self._status = "opened" def dump(self): @@ -381,3 +424,6 @@ class Project: "filename": self._filename, "status": self._status } + + def __repr__(self): + return "".format(self._name, self._id) diff --git a/gns3server/controller/topology.py b/gns3server/controller/topology.py index bff056ba..74525a74 100644 --- a/gns3server/controller/topology.py +++ b/gns3server/controller/topology.py @@ -33,7 +33,8 @@ def project_to_topology(project): "topology": { "nodes": [], "links": [], - "computes": [] + "computes": [], + "items": [] }, "type": "topology", "revision": GNS3_FILE_FORMAT_REVISION, @@ -46,6 +47,8 @@ def project_to_topology(project): data["topology"]["nodes"].append(node.__json__(topology_dump=True)) for link in project.links.values(): data["topology"]["links"].append(link.__json__(topology_dump=True)) + for item in project.items.values(): + data["topology"]["items"].append(item.__json__(topology_dump=True)) for compute in computes: if hasattr(compute, "__json__"): data["topology"]["computes"].append(compute.__json__(topology_dump=True)) diff --git a/gns3server/handlers/api/controller/__init__.py b/gns3server/handlers/api/controller/__init__.py index 9fd0827f..768e7583 100644 --- a/gns3server/handlers/api/controller/__init__.py +++ b/gns3server/handlers/api/controller/__init__.py @@ -20,3 +20,4 @@ from .project_handler import ProjectHandler from .node_handler import NodeHandler from .link_handler import LinkHandler from .server_handler import ServerHandler +from .item_handler import ItemHandler diff --git a/gns3server/handlers/api/controller/item_handler.py b/gns3server/handlers/api/controller/item_handler.py new file mode 100644 index 00000000..815ea8a0 --- /dev/null +++ b/gns3server/handlers/api/controller/item_handler.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import aiohttp + +from gns3server.web.route import Route +from gns3server.controller import Controller + +from gns3server.schemas.item import ( + ITEM_OBJECT_SCHEMA, +) + + +class ItemHandler: + """ + API entry point for Item + """ + + @Route.get( + r"/projects/{project_id}/items", + parameters={ + "project_id": "Project UUID" + }, + status_codes={ + 200: "List of items returned", + }, + description="List items of a project") + def list_items(request, response): + + controller = Controller.instance() + project = controller.get_project(request.match_info["project_id"]) + response.json([v for v in project.items.values()]) + + @Route.post( + r"/projects/{project_id}/items", + parameters={ + "project_id": "Project UUID" + }, + status_codes={ + 201: "Item created", + 400: "Invalid request" + }, + description="Create a new item instance", + input=ITEM_OBJECT_SCHEMA, + output=ITEM_OBJECT_SCHEMA) + def create(request, response): + + controller = Controller.instance() + project = controller.get_project(request.match_info["project_id"]) + item = yield from project.add_item(**request.json) + response.set_status(201) + response.json(item) + + @Route.put( + r"/projects/{project_id}/items/{item_id}", + parameters={ + "project_id": "Project UUID", + "item_id": "Item UUID" + }, + status_codes={ + 201: "Item updated", + 400: "Invalid request" + }, + description="Create a new item instance", + input=ITEM_OBJECT_SCHEMA, + output=ITEM_OBJECT_SCHEMA) + def update(request, response): + + controller = Controller.instance() + project = controller.get_project(request.match_info["project_id"]) + item = project.get_item(request.match_info["item_id"]) + yield from item.update(**request.json) + response.set_status(201) + response.json(item) + + @Route.delete( + r"/projects/{project_id}/items/{item_id}", + parameters={ + "project_id": "Project UUID", + "item_id": "Item UUID" + }, + status_codes={ + 204: "Item deleted", + 400: "Invalid request" + }, + description="Delete a item instance") + def delete(request, response): + + controller = Controller.instance() + project = controller.get_project(request.match_info["project_id"]) + yield from project.delete_item(request.match_info["item_id"]) + response.set_status(204) + diff --git a/gns3server/schemas/item.py b/gns3server/schemas/item.py new file mode 100644 index 00000000..5e22b2d4 --- /dev/null +++ b/gns3server/schemas/item.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +ITEM_OBJECT_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "An item object", + "type": "object", + "properties": { + "item_id": { + "description": "Link UUID", + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "project_id": { + "description": "Project UUID", + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "x": { + "description": "X property", + "type": "integer" + }, + "y": { + "description": "Y property", + "type": "integer" + }, + "z": { + "description": "Z property", + "type": "integer" + }, + "svg": { + "description": "SVG content of the item", + "type": "string", + "pattern": "^<.+>$" + } + }, + "additionalProperties": False +} + + diff --git a/tests/controller/test_item.py b/tests/controller/test_item.py new file mode 100644 index 00000000..6202da24 --- /dev/null +++ b/tests/controller/test_item.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +# +# Copyright (C) 2016 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from unittest.mock import MagicMock +import pytest +import uuid + +from tests.utils import AsyncioMagicMock + + +from gns3server.controller.item import Item +from gns3server.controller.project import Project + + +@pytest.fixture +def project(controller, async_run): + return async_run(controller.add_project()) + + +@pytest.fixture +def item(project): + return Item(project, None, svg="") + + +def test_init_without_uuid(project): + item = Item(project, None, svg="") + assert item.id is not None + + +def test_init_with_uuid(project): + id = str(uuid.uuid4()) + item = Item(project, id, svg="") + assert item.id == id + + +def test_json(project): + i = Item(project, None, svg="") + assert i.__json__() == { + "item_id": i.id, + "project_id": project.id, + "x": i.x, + "y": i.y, + "z": i.z + } + assert i.__json__(topology_dump=True) == { + "item_id": i.id, + "x": i.x, + "y": i.y, + "z": i.z + } + + +def test_update(item, project, async_run, controller): + controller._notification = AsyncioMagicMock() + project.dump = MagicMock() + + async_run(item.update(x=42)) + assert item.x == 42 + controller._notification.emit.assert_called_with("item.updated", item.__json__()) + assert project.dump.called diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 9d29050e..cc094291 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -213,6 +213,32 @@ def test_deleteLink(async_run, project, controller): assert len(project._links) == 0 +def test_addItem(async_run, project, controller): + controller.notification.emit = MagicMock() + + item = async_run(project.add_item(None, svg="")) + assert len(project._items) == 1 + controller.notification.emit.assert_any_call("item.created", item.__json__()) + + +def test_getItem(async_run, project): + item = async_run(project.add_item(None)) + assert project.get_item(item.id) == item + + with pytest.raises(aiohttp.web_exceptions.HTTPNotFound): + project.get_item("test") + + +def test_deleteItem(async_run, project, controller): + assert len(project._items) == 0 + item = async_run(project.add_item()) + assert len(project._items) == 1 + controller._notification = MagicMock() + async_run(project.delete_item(item.id)) + controller.notification.emit.assert_any_call("item.deleted", item.__json__()) + assert len(project._items) == 0 + + def test_delete(async_run, project, controller): assert os.path.exists(project.path) async_run(project.delete()) diff --git a/tests/controller/test_topology.py b/tests/controller/test_topology.py index d7b27e69..886cdfdc 100644 --- a/tests/controller/test_topology.py +++ b/tests/controller/test_topology.py @@ -37,7 +37,8 @@ def test_project_to_topology_empty(tmpdir): "topology": { "nodes": [], "links": [], - "computes": [] + "computes": [], + "items": [] }, "type": "topology", "version": __version__ @@ -57,11 +58,14 @@ def test_basic_topology(tmpdir, async_run, controller): async_run(link.add_node(node1, 0, 0)) async_run(link.add_node(node2, 0, 0)) + item = async_run(project.add_item(svg="")) + topo = project_to_topology(project) assert len(topo["topology"]["nodes"]) == 2 assert node1.__json__(topology_dump=True) in topo["topology"]["nodes"] assert topo["topology"]["links"][0] == link.__json__(topology_dump=True) assert topo["topology"]["computes"][0] == compute.__json__(topology_dump=True) + assert topo["topology"]["items"][0] == item.__json__(topology_dump=True) def test_load_topology(tmpdir): @@ -72,7 +76,8 @@ def test_load_topology(tmpdir): "topology": { "nodes": [], "links": [], - "computes": [] + "computes": [], + "items": [] }, "type": "topology", "version": __version__} diff --git a/tests/handlers/api/controller/test_item.py b/tests/handlers/api/controller/test_item.py new file mode 100644 index 00000000..8b728cf8 --- /dev/null +++ b/tests/handlers/api/controller/test_item.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +This test suite check /project endpoint +""" + +import uuid +import os +import asyncio +import aiohttp +import pytest + + +from tests.utils import asyncio_patch + +from gns3server.handlers.api.controller.project_handler import ProjectHandler +from gns3server.controller import Controller +from gns3server.controller.item import Item + + + +@pytest.fixture +def project(http_controller, async_run): + return async_run(Controller.instance().add_project()) + + +def test_create_item(http_controller, tmpdir, project, async_run): + + response = http_controller.post("/projects/{}/items".format(project.id), { + "svg": '', + "x": 10, + "y": 20, + "z": 0 + }, example=True) + assert response.status == 201 + assert response.json["item_id"] is not None + + +def test_update_item(http_controller, tmpdir, project, async_run): + + response = http_controller.post("/projects/{}/items".format(project.id), { + "svg": '', + "x": 10, + "y": 20, + "z": 0 + },) + response = http_controller.put("/projects/{}/items/{}".format(project.id, response.json["item_id"]), { + "x": 42, + }, example=True) + assert response.status == 201 + assert response.json["x"] == 42 + + +def test_list_item(http_controller, tmpdir, project, async_run): + response = http_controller.post("/projects/{}/items".format(project.id), { + "svg": '', + "x": 10, + "y": 20, + "z": 0 + }, example=False) + response = http_controller.get("/projects/{}/items".format(project.id), example=True) + assert response.status == 200 + assert len(response.json) == 1 + + +def test_delete_item(http_controller, tmpdir, project, async_run): + + item = Item(project) + project._items = {item.id: item} + response = http_controller.delete("/projects/{}/items/{}".format(project.id, item.id), example=True) + assert response.status == 204 + assert item.id not in project._items