Skip to content

Commit

Permalink
API for tags (python-discord#34)
Browse files Browse the repository at this point in the history
* Help page and misc improvements

Committing so I can go home >:|

* [WIP] - API improvements for the tag features. Not completed.

* renaming tag.py to tags.py and refactoring the nomenclature of docs to tags

* fixed error message in tags, cleaning up app_test.py

* tests for the tags feature

* ignoring jsonify returns cause coverall can't handle them

* Catch-all error view for the API blueprint

* cleaning up APIErrorView a little

* bringing coverage for tags.py to 100%

* how did this get in here?

* how did this get in here? ROUND 2

* Removing the 503 database error handling. It's not in use and we should probably rethink that whole custom error handling system anyway.

* Converting the tags file to use the @api_params decorator instead of validating manually. Tested with bot staging.
  • Loading branch information
lemonsaurus authored and Jeremiah Boby committed Mar 6, 2018
1 parent 6be52b1 commit 5d685b2
Show file tree
Hide file tree
Showing 9 changed files with 254 additions and 79 deletions.
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
[run]
omit = /usr/*, gunicorn_config.py, deploy.py, app_test.py, app.py, pysite/websockets.py, pysite/views/*__init__.py, pysite/route_manager.py

[report]
exclude_lines = return jsonify
81 changes: 62 additions & 19 deletions app_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,25 @@


class SiteTest(TestCase):
""" extend TestCase with flask app instantiation """
""" Extend TestCase with flask app instantiation """

def create_app(self):
""" add flask app configuration settings """
""" Add flask app configuration settings """
server_name = 'pytest.local'

app.config['TESTING'] = True
app.config['LIVESERVER_TIMEOUT'] = 10
app.config['SERVER_NAME'] = server_name
app.config['API_SUBDOMAIN'] = f'http://api.{server_name}'
app.config['STAFF_SUBDOMAIN'] = f'http://staff.{server_name}'
app.allow_subdomain_redirects = True

return app


class BaseEndpoints(SiteTest):
""" test cases for the base endpoints """
class RootEndpoint(SiteTest):
""" Test cases for the root endpoint and error handling """

def test_index(self):
""" Check the root path reponds with 200 OK """
response = self.client.get('/', 'http://pytest.local')
Expand Down Expand Up @@ -104,35 +108,74 @@ def test_api_healthcheck(self):
self.assertEqual(response.json, {'status': 'ok'})
self.assertEqual(response.status_code, 200)

def test_api_tag(self):
""" Check tag api """
def test_api_tags(self):
""" Check tag API """
os.environ['BOT_API_KEY'] = 'abcdefg'
headers = {'X-API-Key': 'abcdefg', 'Content-Type': 'application/json'}
good_data = json.dumps({

post_data = json.dumps({
'tag_name': 'testing',
'tag_content': 'testing',
'tag_category': 'testing'})
'tag_content': 'testing'
})

get_data = json.dumps({
'tag_name': 'testing'
})

bad_data = json.dumps({
'not_a_valid_key': 'testing',
'tag_content': 'testing',
'tag_category': 'testing'})
'not_a_valid_key': 'gross_faceman'
})

# POST method - no headers
response = self.client.post('/tags', app.config['API_SUBDOMAIN'])
self.assertEqual(response.status_code, 401)

# POST method - no data
response = self.client.post('/tags', app.config['API_SUBDOMAIN'], headers=headers)
self.assertEqual(response.status_code, 400)

# POST method - bad data
response = self.client.post('/tags', app.config['API_SUBDOMAIN'], headers=headers, data=bad_data)
self.assertEqual(response.status_code, 400)

response = self.client.get('/tag', app.config['API_SUBDOMAIN'])
# POST method - save tag
response = self.client.post('/tags', app.config['API_SUBDOMAIN'], headers=headers, data=post_data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"success": True})

# GET method - no headers
response = self.client.get('/tags', app.config['API_SUBDOMAIN'])
self.assertEqual(response.status_code, 401)

response = self.client.get('/tag', app.config['API_SUBDOMAIN'], headers=headers)
# GET method - get all tags
response = self.client.get('/tags', app.config['API_SUBDOMAIN'], headers=headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(type(response.json), list)

response = self.client.post('/tag', app.config['API_SUBDOMAIN'], headers=headers, data=bad_data)
# GET method - get specific tag
response = self.client.get('/tags?tag_name=testing', app.config['API_SUBDOMAIN'], headers=headers)
self.assertEqual(response.json, {
'tag_content': 'testing',
'tag_name': 'testing'
})
self.assertEqual(response.status_code, 200)

# DELETE method - no headers
response = self.client.delete('/tags', app.config['API_SUBDOMAIN'])
self.assertEqual(response.status_code, 401)

# DELETE method - no data
response = self.client.delete('/tags', app.config['API_SUBDOMAIN'], headers=headers)
self.assertEqual(response.status_code, 400)

response = self.client.post('/tag', app.config['API_SUBDOMAIN'], headers=headers, data=good_data)
self.assertEqual(response.json, {'success': True})
# DELETE method - bad data
response = self.client.delete('/tags', app.config['API_SUBDOMAIN'], headers=headers, data=bad_data)
self.assertEqual(response.status_code, 400)

response = self.client.get('/tag', app.config['API_SUBDOMAIN'], headers=headers, data=good_data)
self.assertEqual(response.json, [{'tag_name': 'testing'}])
# DELETE method - delete the testing tag
response = self.client.delete('/tags', app.config['API_SUBDOMAIN'], headers=headers, data=get_data)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"success": True})

def test_api_user(self):
""" Check insert user """
Expand Down
3 changes: 2 additions & 1 deletion pysite/base_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ class APIView(RouteView):
... return self.error(ErrorCodes.unknown_route)
"""

def error(self, error_code: ErrorCodes) -> Response:
def error(self, error_code: ErrorCodes, error_info: str = "") -> Response:
"""
Generate a JSON response for you to return from your handler, for a specific type of API error
:param error_code: The type of error to generate a response for - see `constants.ErrorCodes` for more
:param error_info: An optional message with more information about the error.
:return: A Flask Response object that you can return from your handler
"""

Expand Down
30 changes: 29 additions & 1 deletion pysite/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,34 @@ def create_table(self, table_name: str, primary_key: str="id", durability: str="
self.log.debug(f"Table created: '{table_name}'")
return True

def delete(self, table_name: str, primary_key: Optional[str] = None,
durability: str = "hard", return_changes: Union[bool, str] = False
) -> Union[Dict[str, Any], None]:
"""
Delete one or all documents from a table. This can only delete
either the contents of an entire table, or a single document.
For more complex delete operations, please use self.query.
:param table_name: The name of the table to delete from. This must be provided.
:param primary_key: The primary_key to delete from that table. This is optional.
:param durability: "hard" (the default) to write the change immediately, "soft" otherwise
:param return_changes: Whether to return a list of changed values or not - defaults to False
:return: if return_changes is True, returns a dict containing all changes. Else, returns None.
"""

if primary_key:
query = self.query(table_name).get(primary_key).delete(
durability=durability, return_changes=return_changes
)
else:
query = self.query(table_name).delete(
durability=durability, return_changes=return_changes
)

if return_changes:
return self.run(query, coerce=dict)
self.run(query)

def drop_table(self, table_name: str):
"""
Attempt to drop a table from the database, along with its data
Expand Down Expand Up @@ -168,7 +196,7 @@ def run(self, query: RqlMethodQuery, *, new_connection: bool=False,
:param connect_database: If creating a new connection, whether to connect to the database immediately
:param coerce: Optionally, an object type to attempt to coerce the result to
:return: THe result of the operation
:return: The result of the operation
"""

if not new_connection:
Expand Down
1 change: 1 addition & 0 deletions pysite/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def inner(self, *args, **kwargs):

if not isinstance(data, list):
data = [data]

except JSONDecodeError:
return self.error(ErrorCodes.bad_data_format) # pragma: no cover

Expand Down
2 changes: 1 addition & 1 deletion pysite/route_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(self):
self.db = RethinkDB()
self.log = logging.getLogger()
self.app.secret_key = os.environ.get("WEBPAGE_SECRET_KEY", "super_secret")
self.app.config["SERVER_NAME"] = os.environ.get("SERVER_NAME", "pythondiscord.com:8080")
self.app.config["SERVER_NAME"] = os.environ.get("SERVER_NAME", "pythondiscord.local:8080")
self.app.before_request(self.db.before_request)
self.app.teardown_request(self.db.teardown_request)

Expand Down
57 changes: 0 additions & 57 deletions pysite/views/api/bot/tag.py

This file was deleted.

116 changes: 116 additions & 0 deletions pysite/views/api/bot/tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# coding=utf-8

from flask import jsonify
from schema import Schema, Optional

from pysite.base_route import APIView
from pysite.constants import ValidationTypes
from pysite.decorators import api_key, api_params
from pysite.mixins import DBMixin

GET_SCHEMA = Schema([
{
Optional("tag_name"): str
}
])

POST_SCHEMA = Schema([
{
"tag_name": str,
"tag_content": str
}
])

DELETE_SCHEMA = Schema([
{
"tag_name": str
}
])


class TagsView(APIView, DBMixin):
path = "/tags"
name = "tags"
table_name = "tags"
table_primary_key = "tag_name"

@api_key
@api_params(schema=GET_SCHEMA, validation_type=ValidationTypes.params)
def get(self, params=None):
"""
Fetches tags from the database.
- If tag_name is provided, it fetches
that specific tag.
- If tag_category is provided, it fetches
all tags in that category.
- If nothing is provided, it will
fetch a list of all tag_names.
Data must be provided as params.
API key must be provided as header.
"""

tag_name = None

if params:
tag_name = params[0].get("tag_name")

if tag_name:
data = self.db.get(self.table_name, tag_name) or {}
else:
data = self.db.pluck(self.table_name, "tag_name") or []

return jsonify(data)

@api_key
@api_params(schema=POST_SCHEMA, validation_type=ValidationTypes.json)
def post(self, json_data):
"""
If the tag_name doesn't exist, this
saves a new tag in the database.
If the tag_name already exists,
this will edit the existing tag.
Data must be provided as JSON.
API key must be provided as header.
"""

json_data = json_data[0]

tag_name = json_data.get("tag_name")
tag_content = json_data.get("tag_content")

self.db.insert(
self.table_name,
{
"tag_name": tag_name,
"tag_content": tag_content
},
conflict="update" # If it exists, update it.
)

return jsonify({"success": True})

@api_key
@api_params(schema=DELETE_SCHEMA, validation_type=ValidationTypes.json)
def delete(self, data):
"""
Deletes a tag from the database.
Data must be provided as JSON.
API key must be provided as header.
"""

json = data[0]
tag_name = json.get("tag_name")

self.db.delete(
self.table_name,
tag_name
)

return jsonify({"success": True})
Loading

0 comments on commit 5d685b2

Please sign in to comment.