Merge "Refactor REST API to work on top of DB API"

This commit is contained in:
Jenkins 2014-02-17 12:58:27 +00:00 committed by Gerrit Code Review
commit f4915a66b6
8 changed files with 262 additions and 184 deletions

View File

@ -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
View 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

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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'])

View File

@ -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'])

View File

@ -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