Merge "Refactor REST API to work on top of DB API"
This commit is contained in:
commit
f4915a66b6
@ -53,7 +53,7 @@ Permission
|
||||
|
||||
Project
|
||||
=======
|
||||
.. autotype:: storyboard.api.v1.wsme_models.Project
|
||||
.. autotype:: storyboard.api.v1.projects.Project
|
||||
:members:
|
||||
|
||||
ProjectGroup
|
||||
@ -63,7 +63,7 @@ ProjectGroup
|
||||
|
||||
Story
|
||||
=====
|
||||
.. autotype:: storyboard.api.v1.wsme_models.Story
|
||||
.. autotype:: storyboard.api.v1.stories.Story
|
||||
:members:
|
||||
|
||||
StoryTag
|
||||
|
55
storyboard/api/v1/base.py
Normal file
55
storyboard/api/v1/base.py
Normal file
@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2013 Mirantis Inc.
|
||||
#
|
||||
# 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 datetime import datetime
|
||||
|
||||
from wsme import types as wtypes
|
||||
|
||||
|
||||
class APIBase(wtypes.Base):
|
||||
|
||||
# TODO(ruhe): add docstrings
|
||||
id = int
|
||||
created_at = datetime
|
||||
updated_at = datetime
|
||||
|
||||
@classmethod
|
||||
def from_db_model(cls, db_model, skip_fields=None):
|
||||
"""Returns the database representation of the given transfer object."""
|
||||
skip_fields = skip_fields or []
|
||||
data = dict((k, v) for k, v in db_model.as_dict().items()
|
||||
if k not in skip_fields)
|
||||
return cls(**data)
|
||||
|
||||
def as_dict(self, omit_unset=False):
|
||||
"""Converts this object into dictionary."""
|
||||
attribute_names = [a.name for a in self._wsme_attributes]
|
||||
|
||||
if omit_unset:
|
||||
attribute_names = [n for n in attribute_names
|
||||
if getattr(self, n) != wtypes.Unset]
|
||||
|
||||
values = dict((name, self._lookup(name)) for name in attribute_names)
|
||||
return values
|
||||
|
||||
def _lookup(self, key):
|
||||
"""Looks up a key, translating WSME's Unset into Python's None.
|
||||
|
||||
:return: value of the given attribute; None if it is not set
|
||||
"""
|
||||
value = getattr(self, key)
|
||||
if value == wtypes.Unset:
|
||||
value = None
|
||||
return value
|
@ -14,10 +14,36 @@
|
||||
# limitations under the License.
|
||||
|
||||
from pecan import rest
|
||||
from wsme.exc import ClientSideError
|
||||
from wsme import types as wtypes
|
||||
import wsmeext.pecan as wsme_pecan
|
||||
|
||||
import storyboard.api.v1.wsme_models as wsme_models
|
||||
from storyboard.api.v1 import base
|
||||
from storyboard.db import api as dbapi
|
||||
|
||||
|
||||
class Project(base.APIBase):
|
||||
"""The Storyboard Registry describes the open source world as ProjectGroups
|
||||
and Products. Each ProjectGroup may be responsible for several Projects.
|
||||
For example, the OpenStack Infrastructure Project has Zuul, Nodepool,
|
||||
Storyboard as Projects, among others.
|
||||
"""
|
||||
|
||||
name = wtypes.text
|
||||
"""At least one lowercase letter or number, followed by letters, numbers,
|
||||
dots, hyphens or pluses. Keep this name short; it is used in URLs.
|
||||
"""
|
||||
|
||||
description = wtypes.text
|
||||
"""Details about the project's work, highlights, goals, and how to
|
||||
contribute. Use plain text, paragraphs are preserved and URLs are
|
||||
linked in pages.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def sample(cls):
|
||||
return cls(
|
||||
name="Storyboard",
|
||||
description="Awesome project")
|
||||
|
||||
|
||||
class ProjectsController(rest.RestController):
|
||||
@ -26,21 +52,39 @@ class ProjectsController(rest.RestController):
|
||||
At this moment it provides read-only operations.
|
||||
"""
|
||||
|
||||
@wsme_pecan.wsexpose(wsme_models.Project, unicode)
|
||||
def get_one(self, name):
|
||||
@wsme_pecan.wsexpose(Project, unicode)
|
||||
def get_one(self, project_id):
|
||||
"""Retrieve information about the given project.
|
||||
|
||||
:param name: project name.
|
||||
:param project_id: project ID.
|
||||
"""
|
||||
project = wsme_models.Project.get(name=name)
|
||||
if not project:
|
||||
raise ClientSideError("Project %s not found" % name,
|
||||
status_code=404)
|
||||
return project
|
||||
|
||||
@wsme_pecan.wsexpose([wsme_models.Project])
|
||||
def get(self):
|
||||
project = dbapi.project_get(project_id)
|
||||
return Project.from_db_model(project)
|
||||
|
||||
@wsme_pecan.wsexpose([Project])
|
||||
def get_all(self):
|
||||
"""Retrieve a list of projects.
|
||||
"""
|
||||
projects = wsme_models.Project.get_all()
|
||||
return projects
|
||||
projects = dbapi.project_get_all()
|
||||
return [Project.from_db_model(p) for p in projects]
|
||||
|
||||
@wsme_pecan.wsexpose(Project, body=Project)
|
||||
def post(self, project):
|
||||
"""Create a new project
|
||||
|
||||
:param project: a project within the request body
|
||||
"""
|
||||
result = dbapi.project_create(project.as_dict())
|
||||
return Project.from_db_model(result)
|
||||
|
||||
@wsme_pecan.wsexpose(Project, int, body=Project)
|
||||
def put(self, project_id, project):
|
||||
"""Modify this project.
|
||||
|
||||
:param project_id: An ID of the project.
|
||||
:param project: a project within the request body.
|
||||
"""
|
||||
result = dbapi.project_update(project_id,
|
||||
project.as_dict(omit_unset=True))
|
||||
return Project.from_db_model(result)
|
||||
|
@ -14,82 +14,74 @@
|
||||
# limitations under the License.
|
||||
|
||||
from pecan import rest
|
||||
from wsme.exc import ClientSideError
|
||||
from wsme import types as wtypes
|
||||
import wsmeext.pecan as wsme_pecan
|
||||
|
||||
import storyboard.api.v1.wsme_models as wsme_models
|
||||
from storyboard.api.v1 import base
|
||||
from storyboard.db import api as dbapi
|
||||
|
||||
|
||||
class Story(base.APIBase):
|
||||
"""Represents a user-story."""
|
||||
|
||||
title = wtypes.text
|
||||
"""A descriptive label for this tracker to show in listings."""
|
||||
|
||||
description = wtypes.text
|
||||
"""A brief introduction or overview of this bug tracker instance."""
|
||||
|
||||
is_bug = bool
|
||||
"""Is this a bug or a feature :)"""
|
||||
|
||||
#todo(nkonovalov): replace with a enum
|
||||
priority = wtypes.text
|
||||
"""Priority.
|
||||
Allowed values: ['Undefined', 'Low', 'Medium', 'High', 'Critical'].
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def sample(cls):
|
||||
return cls(
|
||||
title="Use Storyboard to manage Storyboard",
|
||||
description="We should use Storyboard to manage Storyboard",
|
||||
is_bug=False,
|
||||
priority='Critical')
|
||||
|
||||
|
||||
class StoriesController(rest.RestController):
|
||||
"""Manages operations on stories."""
|
||||
|
||||
_custom_actions = {
|
||||
"add_task": ["POST"],
|
||||
"add_comment": ["POST"]
|
||||
}
|
||||
|
||||
@wsme_pecan.wsexpose(wsme_models.Story, unicode)
|
||||
def get_one(self, id):
|
||||
@wsme_pecan.wsexpose(Story, unicode)
|
||||
def get_one(self, story_id):
|
||||
"""Retrieve details about one story.
|
||||
|
||||
:param id: An ID of the story.
|
||||
:param story_id: An ID of the story.
|
||||
"""
|
||||
story = wsme_models.Story.get(id=id)
|
||||
if not story:
|
||||
raise ClientSideError("Story %s not found" % id,
|
||||
status_code=404)
|
||||
return story
|
||||
story = dbapi.story_get(story_id)
|
||||
return Story.from_db_model(story)
|
||||
|
||||
@wsme_pecan.wsexpose([wsme_models.Story])
|
||||
@wsme_pecan.wsexpose([Story])
|
||||
def get(self):
|
||||
"""Retrieve definitions of all of the stories."""
|
||||
stories = wsme_models.Story.get_all()
|
||||
return stories
|
||||
stories = dbapi.story_get_all()
|
||||
return [Story.from_db_model(s) for s in stories]
|
||||
|
||||
@wsme_pecan.wsexpose(wsme_models.Story, wsme_models.Story)
|
||||
@wsme_pecan.wsexpose(Story, body=Story)
|
||||
def post(self, story):
|
||||
"""Create a new story.
|
||||
|
||||
:param story: a story within the request body.
|
||||
"""
|
||||
created_story = wsme_models.Story.create(wsme_entry=story)
|
||||
if not created_story:
|
||||
raise ClientSideError("Could not create a story")
|
||||
return created_story
|
||||
created_story = dbapi.story_create(story.as_dict())
|
||||
return Story.from_db_model(created_story)
|
||||
|
||||
@wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Story)
|
||||
@wsme_pecan.wsexpose(Story, int, body=Story)
|
||||
def put(self, story_id, story):
|
||||
"""Modify this story.
|
||||
|
||||
:param story_id: An ID of the story.
|
||||
:param story: a story within the request body.
|
||||
"""
|
||||
updated_story = wsme_models.Story.update("id", story_id, story)
|
||||
if not updated_story:
|
||||
raise ClientSideError("Could not update story %s" % story_id)
|
||||
return updated_story
|
||||
|
||||
@wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Task)
|
||||
def add_task(self, story_id, task):
|
||||
"""Associate a task with a story.
|
||||
|
||||
:param story_id: An ID of the story.
|
||||
:param task: a task within the request body.
|
||||
"""
|
||||
updated_story = wsme_models.Story.add_task(story_id, task)
|
||||
if not updated_story:
|
||||
raise ClientSideError("Could not add task to story %s" % story_id)
|
||||
return updated_story
|
||||
|
||||
@wsme_pecan.wsexpose(wsme_models.Story, unicode, wsme_models.Comment)
|
||||
def add_comment(self, story_id, comment):
|
||||
"""Add a comment with a story.
|
||||
|
||||
:param story_id: An ID of the story.
|
||||
:param comment: a comment within the request body.
|
||||
"""
|
||||
updated_story = wsme_models.Story.add_comment(story_id, comment)
|
||||
if not updated_story:
|
||||
raise ClientSideError("Could not add comment to story %s"
|
||||
% story_id)
|
||||
return updated_story
|
||||
updated_story = dbapi.story_update(story_id,
|
||||
story.as_dict(omit_unset=True))
|
||||
return Story.from_db_model(updated_story)
|
||||
|
@ -190,31 +190,6 @@ def update_db_model(cls, db_entry, wsme_entry):
|
||||
return db_entry
|
||||
|
||||
|
||||
class Project(_Base):
|
||||
"""The Storyboard Registry describes the open source world as ProjectGroups
|
||||
and Products. Each ProjectGroup may be responsible for several Projects.
|
||||
For example, the OpenStack Infrastructure Project has Zuul, Nodepool,
|
||||
Storyboard as Projects, among others.
|
||||
"""
|
||||
|
||||
name = wtypes.text
|
||||
"""At least one lowercase letter or number, followed by letters, numbers,
|
||||
dots, hyphens or pluses. Keep this name short; it is used in URLs.
|
||||
"""
|
||||
|
||||
description = wtypes.text
|
||||
"""Details about the project's work, highlights, goals, and how to
|
||||
contribute. Use plain text, paragraphs are preserved and URLs are
|
||||
linked in pages.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def sample(cls):
|
||||
return cls(
|
||||
name="Storyboard",
|
||||
description="Awesome project")
|
||||
|
||||
|
||||
class ProjectGroup(_Base):
|
||||
"""Represents a group of projects."""
|
||||
|
||||
@ -280,54 +255,6 @@ class Comment(_Base):
|
||||
author_id=67)
|
||||
|
||||
|
||||
class Story(_Base):
|
||||
"""Represents a user-story."""
|
||||
|
||||
title = wtypes.text
|
||||
"""A descriptive label for this tracker to show in listings."""
|
||||
|
||||
description = wtypes.text
|
||||
"""A brief introduction or overview of this bug tracker instance."""
|
||||
|
||||
is_bug = bool
|
||||
"""Is this a bug or a feature :)"""
|
||||
|
||||
#todo(nkonovalov): replace with a enum
|
||||
priority = wtypes.text
|
||||
"""Priority.
|
||||
Allowed values: ['Undefined', 'Low', 'Medium', 'High', 'Critical'].
|
||||
"""
|
||||
|
||||
tasks = wtypes.ArrayType(Task)
|
||||
"""List of linked tasks."""
|
||||
|
||||
comments = wtypes.ArrayType(Comment)
|
||||
"""List of linked comments."""
|
||||
|
||||
tags = wtypes.ArrayType(StoryTag)
|
||||
"""List of linked tags."""
|
||||
|
||||
@classmethod
|
||||
def add_task(cls, story_id, task):
|
||||
return cls.create_and_add_item("id", story_id, Task, task, "tasks")
|
||||
|
||||
@classmethod
|
||||
def add_comment(cls, story_id, comment):
|
||||
return cls.create_and_add_item("id", story_id, Comment, comment,
|
||||
"comments")
|
||||
|
||||
@classmethod
|
||||
def sample(cls):
|
||||
return cls(
|
||||
title="Use Storyboard to manage Storyboard",
|
||||
description="We should use Storyboard to manage Storyboard",
|
||||
is_bug=False,
|
||||
priority='Critical',
|
||||
tasks=[],
|
||||
comments=[],
|
||||
tags=[])
|
||||
|
||||
|
||||
class User(_Base):
|
||||
"""Represents a user."""
|
||||
|
||||
@ -398,9 +325,7 @@ SQLALCHEMY_TO_WSME = {
|
||||
sqlalchemy_models.Team: Team,
|
||||
sqlalchemy_models.User: User,
|
||||
sqlalchemy_models.ProjectGroup: ProjectGroup,
|
||||
sqlalchemy_models.Project: Project,
|
||||
sqlalchemy_models.Permission: Permission,
|
||||
sqlalchemy_models.Story: Story,
|
||||
sqlalchemy_models.Task: Task,
|
||||
sqlalchemy_models.Comment: Comment,
|
||||
sqlalchemy_models.StoryTag: StoryTag
|
||||
|
63
storyboard/tests/api/test_projects.py
Normal file
63
storyboard/tests/api/test_projects.py
Normal file
@ -0,0 +1,63 @@
|
||||
# Copyright (c) 2013 Mirantis Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
import json
|
||||
|
||||
from storyboard.tests import base
|
||||
|
||||
|
||||
class TestProjects(base.FunctionalTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestProjects, self).setUp()
|
||||
|
||||
self.resource = '/projects'
|
||||
|
||||
self.project_01 = {
|
||||
'name': 'test_project',
|
||||
'description': 'some description'
|
||||
}
|
||||
|
||||
def test_projects_endpoint(self):
|
||||
response = self.get_json(path=self.resource)
|
||||
self.assertEqual([], response)
|
||||
|
||||
def test_create(self):
|
||||
response = self.post_json(self.resource, self.project_01)
|
||||
project = json.loads(response.body)
|
||||
|
||||
self.assertEqual(self.project_01['name'], project['name'])
|
||||
self.assertEqual(self.project_01['description'],
|
||||
project['description'])
|
||||
|
||||
def test_update(self):
|
||||
response = self.post_json(self.resource, self.project_01)
|
||||
original = json.loads(response.body)
|
||||
|
||||
delta = {
|
||||
'id': original['id'],
|
||||
'name': 'new name',
|
||||
'description': 'new description'
|
||||
}
|
||||
|
||||
url = "/projects/%d" % original['id']
|
||||
response = self.put_json(url, delta)
|
||||
updated = json.loads(response.body)
|
||||
|
||||
self.assertEqual(original['id'], updated['id'])
|
||||
self.assertEqual(original['created_at'], updated['created_at'])
|
||||
|
||||
self.assertNotEqual(original['name'], updated['name'])
|
||||
self.assertNotEqual(original['description'],
|
||||
updated['description'])
|
@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (c) 2013 Mirantis Inc.
|
||||
#
|
||||
# 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
|
||||
@ -12,57 +12,56 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
test_storyboard
|
||||
----------------------------------
|
||||
|
||||
Tests for `storyboard` module.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
|
||||
from storyboard.tests import base
|
||||
|
||||
SAMPLE_STORY = {
|
||||
"title": "test_story",
|
||||
"description": "some description"
|
||||
}
|
||||
|
||||
SAMPLE_STORY_REQUEST = {
|
||||
"story": SAMPLE_STORY
|
||||
}
|
||||
|
||||
|
||||
class TestStories(base.FunctionalTest):
|
||||
|
||||
def setUp(self):
|
||||
super(TestStories, self).setUp()
|
||||
self.resource = '/stories'
|
||||
|
||||
self.story_01 = {
|
||||
'title': 'StoryBoard',
|
||||
'description': 'Awesome Task Tracker',
|
||||
'priority': 'High'
|
||||
}
|
||||
|
||||
def test_stories_endpoint(self):
|
||||
response = self.get_json(path="/stories")
|
||||
response = self.get_json(self.resource)
|
||||
self.assertEqual([], response)
|
||||
|
||||
def test_create(self):
|
||||
response = self.post_json("/stories", SAMPLE_STORY_REQUEST)
|
||||
response = self.post_json(self.resource, self.story_01)
|
||||
story = json.loads(response.body)
|
||||
|
||||
self.assertIn("id", story)
|
||||
self.assertIn("created_at", story)
|
||||
self.assertEqual(story["title"], SAMPLE_STORY["title"])
|
||||
self.assertEqual(story["description"], SAMPLE_STORY["description"])
|
||||
url = "%s/%d" % (self.resource, story['id'])
|
||||
story = self.get_json(url)
|
||||
|
||||
self.assertIn('id', story)
|
||||
self.assertIn('created_at', story)
|
||||
self.assertEqual(story['title'], self.story_01['title'])
|
||||
self.assertEqual(story['description'], self.story_01['description'])
|
||||
|
||||
def test_update(self):
|
||||
response = self.post_json("/stories", SAMPLE_STORY_REQUEST)
|
||||
old_story = json.loads(response.body)
|
||||
response = self.post_json(self.resource, self.story_01)
|
||||
original = json.loads(response.body)
|
||||
|
||||
update_request = copy.deepcopy(SAMPLE_STORY_REQUEST)
|
||||
update_request["story_id"] = old_story["id"]
|
||||
update_request["story"]["title"] = "updated_title"
|
||||
update_request["story"]["description"] = "updated_description"
|
||||
delta = {
|
||||
'id': original['id'],
|
||||
'title': 'new title',
|
||||
'description': 'new description'
|
||||
}
|
||||
|
||||
response = self.put_json("/stories", update_request)
|
||||
updated_story = json.loads(response.body)
|
||||
url = "/stories/%d" % original['id']
|
||||
response = self.put_json(url, delta)
|
||||
updated = json.loads(response.body)
|
||||
|
||||
self.assertEqual(updated_story["id"], old_story["id"])
|
||||
self.assertEqual(updated_story["created_at"], old_story["created_at"])
|
||||
self.assertEqual(updated['id'], original['id'])
|
||||
self.assertEqual(updated['created_at'], original['created_at'])
|
||||
|
||||
self.assertNotEqual(updated_story["title"], old_story["title"])
|
||||
self.assertNotEqual(updated_story["description"],
|
||||
old_story["description"])
|
||||
self.assertNotEqual(updated['title'], original['title'])
|
||||
self.assertNotEqual(updated['description'],
|
||||
original['description'])
|
||||
|
@ -98,9 +98,6 @@ class TestCase(testtools.TestCase):
|
||||
CONF.set_override(k, v, group)
|
||||
|
||||
|
||||
PATH_PREFIX = '/v1'
|
||||
|
||||
|
||||
class DbTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@ -117,6 +114,9 @@ class DbTestCase(TestCase):
|
||||
self.useFixture(_DB_CACHE)
|
||||
|
||||
|
||||
PATH_PREFIX = '/v1'
|
||||
|
||||
|
||||
class FunctionalTest(DbTestCase):
|
||||
"""Used for functional tests of Pecan controllers where you need to
|
||||
test your literal application and its integration with the
|
||||
|
Loading…
x
Reference in New Issue
Block a user