Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add handler for rendering pages #7

Merged
merged 6 commits into from Mar 18, 2014
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 24 additions & 0 deletions shortener/api.py
Expand Up @@ -21,6 +21,19 @@ def __init__(self, reactor, config):
self.config = config
self.engine = get_engine(config['connection_string'], reactor)
self.metrics = ShortenerMetrics(reactor, config)
self.load_handlers()

def load_handlers(self):
self.handlers = {}
for handler_config in self.config['handlers']:
[(name, class_path)] = handler_config.items()
parts = class_path.split('.')
module = '.'.join(parts[:-1])
class_name = parts[-1]
handler_module = __import__(module, fromlist=[class_name])
handler_class = getattr(handler_module, class_name)
handler = handler_class(self.config, self.engine)
self.handlers[name] = handler

@handler('/api/create', methods=['PUT'])
@inlineCallbacks
Expand Down Expand Up @@ -52,6 +65,17 @@ def init_account(self, request):

returnValue({'created': not already_exists})

@handler('/api/handler/<string:handler_name>', methods=['GET'])
@inlineCallbacks
def run_handler(self, request, handler_name):
handler = self.handlers.get(handler_name)
if not handler:
request.setResponseCode(http.NOT_FOUND)
returnValue({})
else:
response = yield handler.render(request)
returnValue(response)

@handler('/<string:short_url>', methods=['GET'])
@inlineCallbacks
def resolve_url(self, request, short_url):
Expand Down
Empty file added shortener/handlers/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions shortener/handlers/base.py
@@ -0,0 +1,12 @@
class BaseApiHandler(object):
def __init__(self, config, db_engine):
self.config = config
self.engine = db_engine

def render(self):
"""
Generates a dict that will be returned as json

Should return a Dict
"""
raise NotImplementedError('Subclasses should implement this.')
37 changes: 37 additions & 0 deletions shortener/handlers/dump.py
@@ -0,0 +1,37 @@
from twisted.internet.defer import inlineCallbacks, returnValue
from shortener.handlers.base import BaseApiHandler
from shortener.models import ShortenerTables
from twisted.web import http


class Dump(BaseApiHandler):
def _format(self, row, audit):
return {
'domain': row['domain'],
'user_token': row['user_token'],
'short_url': row['short_url'],
'long_url': row['long_url'],
'created_at': row['created_at'].isoformat(),
'hits': audit['hits']
}

@inlineCallbacks
def render(self, request):
short_url = request.args.get('url')
conn = yield self.engine.connect()
try:
tables = ShortenerTables(self.config['account'], conn)
if not short_url:
request.setResponseCode(http.BAD_REQUEST)
returnValue({'error': 'expected "?url=<short_url>"'})
else:
row = yield tables.get_row_by_short_url(short_url[0], False)

if row:
audit = yield tables.get_audit_row(row['id'])
returnValue(self._format(row, audit))
else:
request.setResponseCode(http.NOT_FOUND)
returnValue({'error': 'short url not found'})
finally:
yield conn.close()
66 changes: 52 additions & 14 deletions shortener/models.py
@@ -1,7 +1,7 @@
import hashlib
from datetime import datetime

from sqlalchemy import Column, Integer, String, Text, DateTime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import.

from sqlalchemy.sql import and_
from twisted.internet.defer import inlineCallbacks, returnValue

Expand All @@ -17,7 +17,7 @@ class NoShortenerTables(ShortenerDBError):


class ShortenerTables(TableCollection):
shortened_urls = make_table(
urls = make_table(
Column("id", Integer(), primary_key=True),
Column("domain", String(255), nullable=False, index=True),
Column("user_token", String(255), nullable=False, index=True),
Expand All @@ -27,6 +27,12 @@ class ShortenerTables(TableCollection):
Column("created_at", DateTime(timezone=False))
)

audit = make_table(
Column("id", Integer(), primary_key=True),
Column('url_id', Integer(), nullable=False),
Column("hits", Integer()),
)

def _format_row(self, row, fields=None):
if row is None:
return None
Expand All @@ -50,16 +56,16 @@ def get_or_create_row(self, domain, user_token, long_url):
domain, user_token, long_url
])).hexdigest()
result = yield self.execute_query(
self.shortened_urls.select().where(and_(
self.shortened_urls.c.domain == domain,
self.shortened_urls.c.user_token == user_token,
self.shortened_urls.c.hash == hashkey
self.urls.select().where(and_(
self.urls.c.domain == domain,
self.urls.c.user_token == user_token,
self.urls.c.hash == hashkey
)).limit(1))
row = yield result.fetchone()

if not row:
yield self.execute_query(
self.shortened_urls.insert().values(
self.urls.insert().values(
domain=domain,
user_token=user_token,
hash=hashkey,
Expand All @@ -69,25 +75,57 @@ def get_or_create_row(self, domain, user_token, long_url):

# We need to return the inserted row
result = yield self.execute_query(
self.shortened_urls.select().where(
self.shortened_urls.c.hash == hashkey
self.urls.select().where(
self.urls.c.hash == hashkey
).limit(1))
row = yield result.fetchone()

yield self.create_audit(row['id'])

returnValue(self._format_row(row))

@inlineCallbacks
def update_short_url(self, row_id, short_url):
yield self.execute_query(
self.shortened_urls.update().where(
self.shortened_urls.c.id == row_id
self.urls.update().where(
self.urls.c.id == row_id
).values(short_url=short_url))

@inlineCallbacks
def get_row_by_short_url(self, short_url):
def get_row_by_short_url(self, short_url, increment=True):
result = yield self.execute_query(
self.urls.select().where(
self.urls.c.short_url == short_url
).limit(1))
row = yield result.fetchone()

if row and increment:
yield self.execute_query(
self.audit.update().where(
self.audit.c.url_id == row['id']
).values(hits=self.audit.c.hits + 1)
)
returnValue(self._format_row(row))

@inlineCallbacks
def create_audit(self, url_id):
result = yield self.execute_query(
self.audit.select().where(
self.audit.c.url_id == url_id
).limit(1))
row = yield result.fetchone()

if not row:
yield self.execute_query(
self.audit.insert().values(url_id=url_id, hits=0)
)
returnValue({})

@inlineCallbacks
def get_audit_row(self, url_id):
result = yield self.execute_query(
self.shortened_urls.select().where(
self.shortened_urls.c.short_url == short_url
self.audit.select().where(
self.audit.c.url_id == url_id
).limit(1))
row = yield result.fetchone()
returnValue(self._format_row(row))
18 changes: 18 additions & 0 deletions shortener/tests/test_api.py
Expand Up @@ -38,6 +38,9 @@ def setUp(self):
'account': self.account,
'connection_string': connection_string,
'graphite_endpoint': 'tcp:www.example.com:80',
'handlers': [
{'dump': 'shortener.handlers.dump.Dump'},
],
}
self.pool = HTTPConnectionPool(reactor, persistent=False)
self.service = ShortenerServiceApp(
Expand Down Expand Up @@ -202,6 +205,21 @@ def test_resolve_url(self):
result = yield self.service.get_row_by_short_url('qH0')
self.assertEqual(result['long_url'], url + '4')

@inlineCallbacks
def test_resolve_url_hits_counter(self):
tables = ShortenerTables(self.account, self.conn)
yield tables.create_tables()

url = 'http://en.wikipedia.org/wiki/Cthulhu'
yield self.service.shorten_url(url)

yield self.service.get_row_by_short_url('qr0')
yield self.service.get_row_by_short_url('qr0')
result = yield self.service.get_row_by_short_url('qr0')

audit = yield tables.get_audit_row(result['id'])
self.assertEqual(audit['hits'], 3)

@inlineCallbacks
def test_short_url_sequencing(self):
yield ShortenerTables(self.account, self.conn).create_tables()
Expand Down
108 changes: 108 additions & 0 deletions shortener/tests/test_handlers.py
@@ -0,0 +1,108 @@
import json
import os
import treq

from twisted.internet import reactor
from twisted.internet.defer import inlineCallbacks
from twisted.web.client import HTTPConnectionPool
from twisted.trial.unittest import TestCase
from twisted.web.server import Site

from aludel.database import MetaData
from shortener.api import ShortenerServiceApp
from shortener.models import ShortenerTables
from shortener.metrics import CarbonClientService
from shortener.tests.doubles import (
DisconnectingStringTransport, StringTransportClientEndpoint)


class TestHandlers(TestCase):
timeout = 5

def _drop_tables(self):
# NOTE: This is a blocking operation!
md = MetaData(bind=self.service.engine._engine)
md.reflect()
md.drop_all()
assert self.service.engine._engine.table_names() == []

@inlineCallbacks
def setUp(self):
reactor.suggestThreadPoolSize(1)
connection_string = os.environ.get(
"SHORTENER_TEST_CONNECTION_STRING", "sqlite://")

self.account = 'test-account'
cfg = {
'host_domain': 'http://wtxt.io',
'account': self.account,
'connection_string': connection_string,
'graphite_endpoint': 'tcp:www.example.com:80',
'handlers': [
{'dump': 'shortener.handlers.dump.Dump'},
],
}
self.pool = HTTPConnectionPool(reactor, persistent=False)
self.service = ShortenerServiceApp(
reactor=reactor,
config=cfg
)

self.tr = DisconnectingStringTransport()
endpoint = StringTransportClientEndpoint(reactor, self.tr)
self.service.metrics.carbon_client = CarbonClientService(endpoint)
self.service.metrics.carbon_client.startService()
yield self.service.metrics.carbon_client.connect_d

site = Site(self.service.app.resource())
self.listener = reactor.listenTCP(0, site, interface='localhost')
self.listener_port = self.listener.getHost().port
self._drop_tables()
self.conn = yield self.service.engine.connect()
self.addCleanup(self.listener.loseConnection)
self.addCleanup(self.pool.closeCachedConnections)

def make_url(self, path):
return 'http://localhost:%s%s' % (self.listener_port, path)

@inlineCallbacks
def test_api_dump(self):
yield ShortenerTables(self.account, self.conn).create_tables()

url = 'http://en.wikipedia.org/wiki/Cthulhu'
yield self.service.shorten_url(url, 'test-user')
yield treq.get(
self.make_url('/qr0'),
allow_redirects=False,
pool=self.pool)

resp = yield treq.get(
self.make_url('/api/handler/dump?url=qr0'),
allow_redirects=False,
pool=self.pool)

self.assertEqual(resp.code, 200)
result = yield treq.json_content(resp)
self.assertEqual(result['user_token'], 'test-user')
self.assertEqual(result['short_url'], 'qr0')
self.assertEqual(result['long_url'], url)
self.assertEqual(result['hits'], 1)
self.assertEqual(result['domain'], 'en.wikipedia.org')

@inlineCallbacks
def test_api_dump_invalid_querystring(self):
yield ShortenerTables(self.account, self.conn).create_tables()

url = 'http://en.wikipedia.org/wiki/Cthulhu'
yield self.service.shorten_url(url, 'test-user')
yield treq.get(
self.make_url('/qr0'),
allow_redirects=False,
pool=self.pool)

resp = yield treq.get(
self.make_url('/api/handler/dump'),
allow_redirects=False,
pool=self.pool)

self.assertEqual(resp.code, 400)