Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

fixes bug 858244 - documentation for public api, r=rhelmer

  • Loading branch information...
commit b0c7aabf0ca5e6cd5d8f4c3ec9116a5681c85d2b 1 parent d9011c7
@peterbe peterbe authored
View
52 crashstats/api/helpers.py
@@ -0,0 +1,52 @@
+import urllib
+import warnings
+import datetime
+from jingo import register
+import jinja2
+
+
+@register.function
+def describe_friendly_type(type_):
+ if type_ is basestring:
+ return "String"
+ if type_ is int:
+ return "Integer"
+ if type_ is list:
+ return "List of strings"
+ if type_ is datetime.date:
+ return "Date"
+ if type_ is datetime.datetime:
+ return "Date and time"
+ warnings.warn("Don't know how to describe type %r" % type_)
+ return type_
+
+
+@register.function
+def make_test_input(parameter, defaults):
+ template = u'<input type="%(type)s" name="%(name)s"'
+ data = {
+ 'name': parameter['name'],
+ }
+ classes = []
+ if parameter['required']:
+ classes.append('required')
+
+ if parameter['type'] is datetime.date:
+ data['type'] = 'date'
+ else:
+ data['type'] = 'text'
+ if parameter['type'] is not basestring:
+ classes.append('validate-%s' % parameter['type'].__name__)
+ if defaults.get(parameter['name']):
+ data['value'] = urllib.quote(unicode(defaults.get(parameter['name'])))
+ else:
+ data['value'] = ''
+
+ data['classes'] = ' '.join(classes)
+ if data['classes']:
+ template += ' class="%(classes)s"'
+ if data['value']:
+ template += ' value="%(value)s"'
+ template += '>'
+ html = template % data
+ return jinja2.Markup(html)
View
158 crashstats/api/static/api/js/testdrive.js
@@ -0,0 +1,158 @@
+(function ($, document) {
+ 'use strict';
+
+ $.fn.serializeExclusive = function() {
+ var o = {};
+ var a = this.serializeArray();
+ $.each(a, function() {
+ var value;
+ if (o[this.name] !== undefined) {
+ if (!o[this.name].push) {
+ o[this.name] = [o[this.name]];
+ }
+ if (this.value)
+ o[this.name].push(this.value || '');
+ } else {
+ if (this.value)
+ o[this.name] = this.value || '';
+ }
+ });
+ return o;
+ };
+
+ function one_more(element) {
+ var container = $(element).parents('td');
+ var previous = $('input', container).eq(-1);
+ var clone = previous.clone();
+ clone.val('');
+ clone.insertAfter(previous);
+ if ($('input', container).length > 1) {
+ $('a.collapse-list', container).css('display', 'inline');
+ }
+ }
+
+ function one_less(element) {
+ var container = $(element).parents('td');
+ var last = $('input', container).eq(-1);
+ last.remove();
+ if ($('input', container).length < 2) {
+ $('a.collapse-list', container).css('display', 'none');
+ }
+ }
+
+ function validate_form(form) {
+
+ function is_int(x) {
+ var y = parseInt(x, 10);
+ if (isNaN(y)) return false;
+ return x==y && x.toString() == y.toString();
+ }
+ var all_valid = true;
+ $('input', form).each(function() {
+ var valid = true;
+ var element = $(this);
+ var value = element.val();
+ if (element.hasClass('required') && !value) {
+ valid = false;
+ } else if (element.hasClass('validate-int') && !is_int(value)) {
+ valid = false;
+ } else {
+ // we can do more validation but let's not go too crazy yet
+ }
+ if (!valid) {
+ all_valid = false;
+ element.addClass('error');
+ } else {
+ element.removeClass('error');
+ }
+ });
+ return all_valid;
+ }
+
+ function submit_form(form) {
+ var url = $('p.url code', form).text();
+ // unlike regular, form.serialize() by doing it this way we get a
+ // query string that only contains actual values
+ // The second parameter (`true`) is so that things like
+ // `{products: ["Firefox", "Thunderbird"]}`
+ // becomes: `products=Firefox&products=Thunderbird`
+ var qs = $.param(form.serializeExclusive(), true);
+ if (qs) {
+ url += '?' + qs;
+ }
+ var ajax_url = url;
+ if (ajax_url.search(/\?/) == -1) ajax_url += '?';
+ else ajax_url += '&';
+ ajax_url += 'pretty=print';
+
+ $.ajax({
+ url: ajax_url,
+ method: 'GET',
+ dataType: 'text',
+ success: function(response, textStatus, jqXHR) {
+ var container = $('.test-drive', form);
+ $('.used-url code', container).text(url);
+ $('.used-url a', container).attr('href', url);
+ $('pre', container).text(response);
+ $('.status code', container).hide();
+ $('.status-error', container).hide();
+ container.show();
+ $('button.close', form).show();
+ },
+ error: function(jqXHR, textStatus, errorThrown) {
+ var container = $('.test-drive', form);
+ $('pre', container).text(jqXHR.responseText);
+ $('.status code', container).text(jqXHR.status).show();
+ $('.status-error', container).show();
+
+ container.show();
+ $('button.close', form).show();
+ }
+ });
+ }
+
+ $(document).ready(function () {
+ $('input.validate-list').each(function() {
+ $('<a href="#">-</a>')
+ .hide()
+ .addClass('collapse-list')
+ .attr('title', 'Click to remove the last added input field')
+ .click(function(e) {
+ e.preventDefault();
+ one_less(this);
+ })
+ .insertAfter($(this));
+ $('<a href="#">+</a>')
+ .addClass('expand-list')
+ .attr('title', 'Click to create another input field')
+ .click(function(e) {
+ e.preventDefault();
+ one_more(this);
+ })
+ .insertAfter($(this));
+ });
+
+ $('form.testdrive').submit(function(event) {
+ var $form = $(this);
+ event.preventDefault();
+ if (validate_form($form)) {
+ submit_form($form);
+ } else {
+ }
+ });
+
+ // Can we PLEASE upgrade to modern jquery soon?
+ $('input.error').live('change', function() {
+ $(this).removeClass('error');
+ });
+
+ $('button.close').click(function(event) {
+ event.preventDefault();
+ $('.test-drive', $(this).parents('form')).hide();
+ $(this).hide();
+ });
+
+ });
+
+
+}($, document));
View
131 crashstats/api/templates/api/documentation.html
@@ -0,0 +1,131 @@
+{% extends "crashstats_base.html" %}
+
+{% block page_title %}API Documentation{% endblock %}
+
+{% block product_nav_filter %}&nbsp;{% endblock %}
+
+{% block site_css %}
+ {{ super() }}
+ <style type="text/css">
+ p.url { margin: 15px; font-size: 1.6em; }
+ p.url code { padding-left: 25px; }
+ th.fixed, td.fixed { width: 15%; }
+ div.run-test { text-align: right; margin: 10px; }
+ a.collapse-list, a.expand-list { text-decoration: none; font-weight: bold; padding: 3px; }
+ input.error { border: 2px solid red; }
+ div.test-drive { display: none; }
+ button.close { display: none; }
+ .status-error { display: none; color: red; font-weight: bold; }
+ pre.docstring { background-color: rgb(225,225,225); padding: 6px; }
+ </style>
+{% endblock %}
+
+
+{% block site_js %}
+ {{ super() }}
+ {% compress js %}
+ <script type="text/javascript" src="{{ static('api/js/testdrive.js') }}"></script>
+ {% endcompress %}
+{% endblock %}
+
+
+{% block content %}
+<div id="mainbody">
+ <div class="page-heading">
+ <h2>API Documentation</h2>
+ </div>
+
+ <div class="panel">
+ <div class="body notitle">
+ <p>
+ These API endpoints are publically available. The parameters to some endpoints
+ are non-trivial as some might require deeper understanding of the Soccoro backend.
+ </p>
+ <p>
+ <b>Note:</b> All Personal Identifiable Information
+ will be <b>removed or scrubbed</b> from all responses.
+ </p>
+ </div>
+ </div>
+
+ {% for endpoint in endpoints %}
+ <div class="panel">
+ <div class="title">
+ <h2>{{ endpoint.name }}</h2>
+ </div>
+ <div class="body">
+ <form class="testdrive">
+ <p class="url">
+ <strong>{{ ' | '.join(endpoint.methods) }}</strong>
+ <code>{{ base_url }}{{ endpoint.url }}</code>
+ </p>
+
+ {% if endpoint.docstring %}
+ <p class="docstring">Documentation notes</p>
+ <pre class="docstring">{{ endpoint.docstring }}</pre>
+ {% endif %}
+
+ {% if endpoint.parameters %}
+ <table class="data-table vertical">
+ <thead>
+ <tr>
+ <th class="fixed">Parameter key</th>
+ <th class="fixed">Required?</th>
+ <th class="fixed">Type</th>
+ <th class="fixed">Default</th>
+ <th>Test drive</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for parameter in endpoint.parameters %}
+ <tr>
+ <td class="fixed"><strong>{{ parameter.name }}</strong></td>
+ <td class="fixed">
+ {% if parameter.required %}Required
+ {% else %}Optional
+ {% endif %}
+ </td>
+ <td class="fixed">
+ {{ describe_friendly_type(parameter['type']) }}
+ </td>
+ <td class="fixed">
+ <code>{{ endpoint['defaults'].get(parameter['name'], '') }}</code>
+ </td>
+ <td>
+ {{ make_test_input(parameter, endpoint['defaults']) }}
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ {% else %}
+ <p><em>Takes no parameters</em></p>
+ {% endif %}
+
+ <!-- for starting a test drive -->
+ <div class="run-test">
+ <button type="submit">Run Test Drive!</button>
+ <button type="button" class="close">&times; Close</button>
+ </div>
+
+ <div class="test-drive">
+ <p class="status-error">
+ An error happened on the server when trying this URL.
+ </p>
+ <p class="used-url">
+ <strong>Using</strong><br>
+ <a href=""><code></code></a>
+ </p>
+ <p class="status">
+ Status <code></code>
+ </p>
+ <p><strong>Output:</strong></p>
+ <pre></pre>
+
+ </div>
+ </form>
+ </div>
+ </div>
+ {% endfor %}
+</div>
+{% endblock %}
View
29 crashstats/api/tests/test_views.py
@@ -1,5 +1,6 @@
import re
import json
+import unittest
from django.core.urlresolvers import reverse
@@ -12,6 +13,34 @@
)
+class TestDedentLeft(unittest.TestCase):
+
+ def test_dedent_left(self):
+ from crashstats.api.views import dedent_left
+ eq_(dedent_left('Hello', 2), 'Hello')
+ eq_(dedent_left(' Hello', 2), ' Hello')
+ eq_(dedent_left(' Hello ', 2), ' Hello ')
+
+ text = """Line 1
+ Line 2
+ Line 3
+ """.rstrip()
+ # because this code right above is indented with 2 * 4 spaces
+ eq_(dedent_left(text, 8), 'Line 1\nLine 2\nLine 3')
+
+
+class TestDocumentationViews(BaseTestViews):
+
+ def test_documentation_home_page(self):
+ url = reverse('api:documentation')
+ response = self.client.get(url)
+ eq_(response.status_code, 200)
+
+ from crashstats.api import views
+ for each in views.BLACKLIST:
+ ok_(each not in response.content)
+
+
class TestViews(BaseTestViews):
def test_invalid_url(self):
View
1  crashstats/api/urls.py
@@ -5,6 +5,7 @@
urlpatterns = patterns(
'', # prefix
+ url('^$', views.documentation, name='documentation'),
url('^(?P<model_name>\w+)/$',
views.model_wrapper,
name='model_wrapper'),
View
83 crashstats/api/views.py
@@ -1,14 +1,16 @@
+import re
import datetime
from django import http
-#from django.shortcuts import render, redirect
+from django.shortcuts import render
+from django.contrib.sites.models import RequestSite
+from django.core.urlresolvers import reverse
+from django import forms
+
from crashstats.crashstats import models
from crashstats.crashstats import utils
-from django import forms
-
-
class MultipleStringField(forms.TypedMultipleChoiceField):
"""Field that do not validate if the field values are in self.choices"""
@@ -87,3 +89,76 @@ def model_wrapper(request, model_name):
result = {'errors': dict(form.errors)}
return result
+
+
+def documentation(request):
+ endpoints = [
+ ]
+
+ for name in dir(models):
+ model = getattr(models, name)
+ try:
+ if not issubclass(model, models.SocorroMiddleware):
+ continue
+ if model is models.SocorroMiddleware:
+ continue
+ if model.__name__ in BLACKLIST:
+ continue
+ except TypeError:
+ # most likely a builtin class or something
+ continue
+ endpoints.append(_describe_model(model))
+
+ base_url = (
+ '%s://%s' % (request.is_secure() and 'https' or 'http',
+ RequestSite(request).domain)
+ )
+ data = {
+ 'endpoints': endpoints,
+ 'base_url': base_url,
+ }
+ return render(request, 'api/documentation.html', data)
+
+
+def _describe_model(model):
+ params = list(model.get_annotated_params())
+ params.sort(key=lambda x: (not x['required'], x['name']))
+ methods = []
+ if model.get:
+ methods.append('GET')
+ elif models.post:
+ methods.append('POST')
+
+ docstring = model.__doc__
+ if docstring:
+ docstring = dedent_left(docstring.rstrip(), 4)
+ data = {
+ 'name': model.__name__,
+ 'url': reverse('api:model_wrapper', args=(model.__name__,)),
+ 'parameters': params,
+ 'defaults': getattr(model, 'defaults', {}),
+ 'methods': methods,
+ 'docstring': docstring,
+ }
+ return data
+
+
+def dedent_left(text, spaces):
+ """
+ If the string is:
+ ' One\n'
+ ' Two\n'
+ 'Three\n'
+
+ And you set @spaces=2
+ Then return this:
+ ' One\n'
+ ' Two\n'
+ 'Three\n'
+ """
+ lines = []
+ regex = re.compile('^\s{%s}' % spaces)
+ for line in text.splitlines():
+ line = regex.sub('', line)
+ lines.append(line)
+ return '\n'.join(lines)
View
13 crashstats/crashstats/utils.py
@@ -35,13 +35,20 @@ def daterange(start_date, end_date, format='%Y-%m-%d'):
def json_view(f):
@functools.wraps(f)
- def wrapper(*args, **kw):
- response = f(*args, **kw)
+ def wrapper(request, *args, **kw):
+ response = f(request, *args, **kw)
if isinstance(response, http.HttpResponse):
return response
else:
+ indent = 0
+ if request.REQUEST.get('pretty') == 'print':
+ indent = 2
return http.HttpResponse(
- _json_clean(json.dumps(response, cls=DateTimeEncoder)),
+ _json_clean(json.dumps(
+ response,
+ cls=DateTimeEncoder,
+ indent=indent
+ )),
content_type='application/json; charset=UTF-8'
)
return wrapper
Please sign in to comment.
Something went wrong with that request. Please try again.