Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
language: python
python:
- '3.6'
- '3.6'
before_script:
- pip3 install -r requirements.txt
- pip3 install -r requirements.txt
after_success:
- coveralls
- coveralls
script:
- pytest -v --cov api/app --cov-report term-missing

- pytest -v --cov api/app --cov-report term-missing
deploy:
provider: heroku
api_key:
secure: Eav/qEQN89dyvqBKam746FbeRAo8GQtfjicoxjev482DFHowK3BvieKiHlUzdMdZiAr38uZb2KYD39KdDV/xTMGKENL+/4ADWVywRl4EaNJd+5ufjx8zhQjElZJMpV1mnHCbVLmbE4v3XU65SbibpyjeDvcUMNKYDSjxuRAv0nsWdR/YFYt0s0hTALAGRb/q369+gWtCaX87UGxwUuMmg0uhl9Ke3ktdTStaytymNt4cToQ5V0LQopNs/lQgSb9ZQgLxhBmtjAqKVmyGQzXZc++Poyexeq1bdMA/AcuyyknmeTbYfIW9UNxUxBwCUCVQ/3qMDS4fyNAOYKlERt0agdxlOmQayEEkSgmrd6+iSTR1foena2pHcS2q6Pl9CncIygHezm/9uYPIhg1HmziNQR+peH4q278gSlDh9Z0TOT7NaBIzreTg8ZzrdmolhXEiLFLsX36OGz6HOjec4n8VOcvkDbEb0oQwU3GYTfAMpEk+XgsswSpii5pW75fmklJrr3dHvifekDpcHDhNMU9QCcU+R2LkkZpG3+8g9qsGyG7yoqveIbVO96M8gjvhORRDh7dt+/4GQ9UOkVSifa4q2rUxyXDCLXFlcvI5SwpUgcinvIjCpWRVQBEpeRMbg1Y/KugEtYMtp9AG02+ITLttTUkzmXGiqYKpgsTpgHZKpCM=
app: andela-stackoverflow
on: staging
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: gunicorn run:app
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ While in the terminal in the `UI` directory
3. `npm run watch` or `yarn run watch` to set up a local development server and watch all the files for changes and live reload

# API
#### This __api__ is currently hosted on [heroku](https://andela-stackoverflow.herokuapp.com/api/v1.0/)
#### Requirements
- [Python](https://www.python.org/) A general purpose programming language
- [Pip](https://pypi.org/project/pip/) A tool for installing python packages
Expand Down Expand Up @@ -99,15 +100,19 @@ pytest
pytest pytest -v --cov api
```
#### API REST End Points
| End Point | Verb |Use |
| ------------------------|---------------|--------------------------------------|
| /api/v1.0/ |GET |Gets a list of all API resources |
| /api/v1.0/questions |GET |Gets a list of Questions |
|/api/v1.0/questions/id |GET |Gets a Question resource of a given ID|
| /api/v1.0/questions |POST |Stores a Question resource |
|/api/v1.0/questions |PATCH / UPDATE |Updates a Question resource |
|/api/v1.0/questions |DELETE |Deletes a Question resource |

| End Point | Verb |Use |
| ----------------------------------------------------|------|--------------------------------------|
|`/api/v1.0/` |GET |API index |
|`/api/v1.0/questions` |GET |Gets a list of Questions |
|`/api/v1.0/questions` |POST |Stores a Question resource |
|`/api/v1.0/questions/<int:id>` |GET |Gets a Question resource of a given ID|
|`/api/v1.0/questions/<int:id> ` |PATCH |Updates a Question resource |
|`/api/v1.0/questions<int:id>` |DELETE|Deletes a Question resource |
|`/api/v1.0/questions/<int:id>/answers` |GET |Gets a answers of a specific question |
|`/api/v1.0/questions/<int:id>/answers` |POST |Adds a an answer to a question |
|`/api/v1.0/questions/<int:id>/answers/<int:id>` |GET |Gets a specific answer |
|`/api/v1.0/questions/<int:id>/answers/<int:id>` |UPDATE|Updates an existing answer |
|`/api/v1.0/questions/<int:id>/answers/<int:id>` |DELETE|Deletes an existing answer |

#### Built With
- [Flask](http://flask.pocoo.org/) A microframework for Python based on Werkzeug, Jinja 2
Expand All @@ -116,5 +121,4 @@ pytest pytest -v --cov api
A Special thanks goes to
1. [Andela](https://andela.com/) for having given me an opportunity to participate in the boot camp, without them , this application wouldn't be a success.

2. [UI Faces](https://uifaces.co/) for providing free avatar sources that I used in the UI templates

2. [UI Faces](https://uifaces.co/) for providing free avatar sources that I used in the UI templates
36 changes: 36 additions & 0 deletions api/app/controllers/AnswersController.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from flask import jsonify, request
from api.app.models import Question, Answer


class AnswersController:
@classmethod
def index(cls, question_id):
question = Question.find_or_fail(question_id).load("answers")
return jsonify(dict(data=question["answers"]))

@classmethod
def show(cls, question_id, answer_id):
answer = Answer.by_question_id(question_id, answer_id)
return jsonify(dict(data=answer)), 200

@classmethod
def store(cls, question_id):
question = Question.find_or_fail(question_id)
answer = question.answers().create(request.validate({
"body": "required"
}))
return jsonify(dict(data=answer)), 201

@classmethod
def update(cls, question_id, answer_id):
answer = Answer.by_question_id(question_id, answer_id).update(
request.validate({
"body": "required"
}))

return jsonify(dict(data=answer)), 200

@classmethod
def destroy(cls, question_id, answer_id):
Answer.by_question_id(question_id, answer_id).delete()
return jsonify(dict(message="Answer was successively removed")), 200
3 changes: 2 additions & 1 deletion api/app/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .HomeController import HomeController
from .QuestionsController import QuestionsController
from .AnswersController import AnswersController


__all__ = ["HomeController", "QuestionsController"]
__all__ = ["HomeController", "QuestionsController", "AnswersController"]
7 changes: 7 additions & 0 deletions api/app/models/Answer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from api.core.storage import Model


class Answer(Model):
@classmethod
def by_question_id(cls, qtn_id, ans_id):
return cls.where(question_id=qtn_id, id=ans_id).first_or_fail()
4 changes: 3 additions & 1 deletion api/app/models/Question.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from api.core.storage import Model
from .Answer import Answer


class Question(Model):
pass
def answers(self):
return self.has_many(Answer)
4 changes: 2 additions & 2 deletions api/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .Question import Question

__all__ = ["Question"]
from .Answer import Answer
__all__ = ["Question", "Answer"]
3 changes: 2 additions & 1 deletion api/app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

Router.group([
Router.get("/", HomeController, "index"),
Router.resource("/questions", QuestionsController)
Router.resource("/questions", QuestionsController),
Router.resource("/questions.answers", AnswersController)
]).prefix("/api/v1.0")
3 changes: 2 additions & 1 deletion api/core/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ def validation_exception(e):


def not_found_exception(e):
return jsonify(e.response), 404
response = e.response or dict(error="Resource doesn't exist")
return jsonify(response), 404


def handle_errors(app):
Expand Down
10 changes: 5 additions & 5 deletions api/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ def __init__(self, errors):


class ModelNotFoundException(NotFound):
def __init__(self, table, id):
response = dict(
error=f"Coudnot find a {table} resource with id: {id}"
)
super().__init__(response=response)
def __init__(self, table=None, id=None):
error = "Couldn't find a given resource"
if table:
error = f"Coudnot find a {table} resource with id: {id}"
super().__init__(response=dict(error=error))
6 changes: 3 additions & 3 deletions api/core/routing/RouteResource.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ def prepare_urls(self, url):
parts = str(url).split(".")
if len(parts) == 1:
self.all_url = parts[0]
self.single_url = f"{parts[0]}/<param>"
self.single_url = f"{parts[0]}/<int:param>"
else:
self.all_url = f"{parts[0]}/<param1>/{parts[1]}"
self.single_url = f"{self.all_url}/<param2>"
self.all_url = f"{parts[0]}/<int:param1>/{parts[1]}"
self.single_url = f"{self.all_url}/<int:param2>"
return self
1 change: 1 addition & 0 deletions api/core/storage/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def children(self):

def load(self):
self.parent[self.child_key] = self._load_data()
return self.parent


class HasMany(Relationship):
Expand Down
15 changes: 11 additions & 4 deletions api/core/storage/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ def first(self):
except IndexError:
return None

def first_or_fail(self):
first = self.first()
if first:
return first
raise ModelNotFoundException()

def __iter__(self):
return iter(self.models)

Expand Down Expand Up @@ -154,6 +160,7 @@ def has_many(self, child, parent_id="id", child_id=None):
def load(self, *args):
for key in args:
self._load_relation_ship(key)
return self

def _load_relation_ship(self, key):
relationship = getattr(self, key)()
Expand All @@ -167,9 +174,9 @@ def find(cls, id):

@classmethod
def find_or_fail(cls, id):
question = cls.find(id)
if question:
return question
model = cls.find(id)
if model:
return model
raise ModelNotFoundException(cls.table_name(), id)

@classmethod
Expand All @@ -191,7 +198,7 @@ def or_where(cls, *args, **kwargs):

@classmethod
def hydrate(cls, models):
return list(map(cls, models))
return [cls(model) for model in models]

def to_json(self):

Expand Down
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
astroid==2.0.4
atomicwrites==1.1.5
attrs==18.1.0
certifi==2018.8.13
chardet==3.0.4
click==6.7
coverage==4.0.3
Flask==1.0.2
gunicorn==19.9.0
idna==2.7
isort==4.3.4
itsdangerous==0.24
Jinja2==2.10
lazy-object-proxy==1.3.1
MarkupSafe==1.0
mccabe==0.6.1
more-itertools==4.3.0
pluggy==0.7.1
py==1.5.4
pylint==2.1.1
pytest==3.7.1
pytest-cov==2.5.1
python-coveralls==2.9.1
PyYAML==3.13
requests==2.19.1
six==1.11.0
typed-ast==1.1.0
urllib3==1.23
Werkzeug==0.14.1
wrapt==1.10.11
3 changes: 3 additions & 0 deletions tests/feature/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ def post(self, url, json=None):
def patch(self, url, json=None):
return self.client.patch(**self._make_options(url, json))

def put(self, url, json=None):
return self.client.put(**self._make_options(url, json))

def delete(self, url):
return self.client.delete(**self._make_options(url))

Expand Down
57 changes: 57 additions & 0 deletions tests/feature/test_QuestionAnswers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from tests.feature import BaseTestCase


class TestQuestionAnswers(BaseTestCase):
def setUp(self):
super().setUp()

self.question = dict(
title="Travis CI",
description="How do I integrate Travis"
)
self.add_question()

def add_question(self):
rv = self.post("/questions", self.question)
self.api_question = rv.get_json()["data"]

def answers_url(self, id=""):
return f"/questions/{self.api_question['id']}/answers"

def answer_url(self, id):
return f"{self.answers_url()}/{id}"

def test_it_fetches_a_list_of_all_answers(self):
rv = self.get(self.answers_url())
self.assertEqual(200, rv.status_code)

def test_add_a_question_answer_returns_a_201_status(self):
rv = self.post(self.answers_url(), dict(body="Some answer"))
self.assertEqual(201, rv.status_code)

def test_add_a_question_answer_passes(self):
answer = dict(body="Some answer")
rv = self.post(self.answers_url(), answer)
self.assertDictContainsSubset(answer, rv.get_json()["data"])

def test_add_a_question_answer_fails_with_invalid_data(self):
rv = self.post(self.answers_url())
self.assertEqual(rv.status_code, 422)

def test_get_existing_question_answer_returns_a_200_response(self):
self.post(self.answers_url(), dict(body="Some existing answer"))
rv = self.get(self.answer_url(1))
self.assertEqual(rv.status_code, 200)

def test_it_updates_an_existing_question_answer(self):
self.post(self.answers_url(), dict(body="Some existing answer"))
update = dict(body="Updated answer")
rv = self.put(self.answer_url(1), update)
answer = rv.get_json()["data"]
self.assertDictContainsSubset(update, answer)

def test_it_deletes_an_existing_question(self):
self.post(self.answers_url(), dict(body="Some existing answer"))
self.delete(self.answer_url(1))
rv = self.get(self.answer_url(1))
self.assertEqual(rv.status_code, 404)
9 changes: 8 additions & 1 deletion tests/unit/test_Storage.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from unittest import TestCase
from collections import Iterable
from api.core.storage import Model, Storage
from api.core.exceptions import ModelException
from api.core.exceptions import ModelException, ModelNotFoundException


class Contact(Model):
Expand Down Expand Up @@ -136,3 +136,10 @@ def test_model_collection_length(self):
def test_model_collection_is_iterable(self):
User.create(self.attributes)
self.assertIsInstance(iter(User.all()), Iterable)

def test_it_first_or_fail_fails(self):
self.assertRaises(ModelNotFoundException, User.all().first_or_fail)

def test_it_first_or_fail_passes(self):
User.create(self.attributes)
self.assertEqual(1, len(User.all()))