Skip to content
30 changes: 20 additions & 10 deletions rest_framework_docs/api_docs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from operator import attrgetter
from django.conf import settings
from django.core.urlresolvers import RegexURLResolver, RegexURLPattern
from rest_framework.views import APIView
Expand All @@ -6,25 +7,34 @@

class ApiDocumentation(object):

def __init__(self):
def __init__(self, filter_app=None):
"""
:param filter_app: namespace or app_name
"""
self.endpoints = []
root_urlconf = __import__(settings.ROOT_URLCONF)
if hasattr(root_urlconf, 'urls'):
self.get_all_view_names(root_urlconf.urls.urlpatterns)
self.get_all_view_names(root_urlconf.urls.urlpatterns, filter_app=filter_app)
else:
self.get_all_view_names(root_urlconf.urlpatterns)
self.get_all_view_names(root_urlconf.urlpatterns, filter_app=filter_app)

def get_all_view_names(self, urlpatterns, parent_pattern=None):
def get_all_view_names(self, urlpatterns, parent_pattern=None, filter_app=None):
for pattern in urlpatterns:
if isinstance(pattern, RegexURLResolver):
self.get_all_view_names(urlpatterns=pattern.url_patterns, parent_pattern=pattern)
if isinstance(pattern, RegexURLResolver) and (not filter_app or filter_app in [pattern.app_name, pattern.namespace]):
self.get_all_view_names(urlpatterns=pattern.url_patterns, parent_pattern=pattern, filter_app=filter_app)
elif isinstance(pattern, RegexURLPattern) and self._is_drf_view(pattern):
api_endpoint = ApiEndpoint(pattern, parent_pattern)
self.endpoints.append(api_endpoint)
if not filter_app or (parent_pattern and filter_app in [parent_pattern.app_name, parent_pattern.namespace]):
api_endpoint = ApiEndpoint(pattern, parent_pattern)
self.endpoints.append(api_endpoint)

def _is_drf_view(self, pattern):
# Should check whether a pattern inherits from DRF's APIView
"""
Should check whether a pattern inherits from DRF's APIView
"""
return hasattr(pattern.callback, 'cls') and issubclass(pattern.callback.cls, APIView)

def get_endpoints(self):
return self.endpoints
"""
Returns the endpoints sorted by the app name
"""
return sorted(self.endpoints, key=attrgetter('name'))
18 changes: 14 additions & 4 deletions rest_framework_docs/api_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
import json
import inspect
from django.contrib.admindocs.views import simplify_regex
from rest_framework.viewsets import ModelViewSet


class ApiEndpoint(object):

def __init__(self, pattern, parent_pattern=None):
self.pattern = pattern
self.callback = pattern.callback
# self.name = pattern.name
self.docstring = self.__get_docstring__()
self.name_parent = simplify_regex(parent_pattern.regex.pattern).replace('/', '') if parent_pattern else None

if parent_pattern:
self.name_parent = parent_pattern.namespace or parent_pattern.app_name or \
simplify_regex(parent_pattern.regex.pattern).replace('/', '-')
self.name = self.name_parent
if hasattr(pattern.callback, 'cls') and issubclass(pattern.callback.cls, ModelViewSet):
self.name = '%s (REST)' % self.name_parent
else:
self.name_parent = ''
self.name = ''

self.labels = dict(parent=self.name_parent, name=self.name)
self.path = self.__get_path__(parent_pattern)
self.allowed_methods = self.__get_allowed_methods__()
# self.view_name = pattern.callback.__name__
self.errors = None
self.fields = self.__get_serializer_fields__()
self.fields_json = self.__get_serializer_fields_json__()
self.permissions = self.__get_permissions_class__()

def __get_path__(self, parent_pattern):
if parent_pattern:
return "/{0}{1}".format(self.name_parent, simplify_regex(self.pattern.regex.pattern))
return simplify_regex(parent_pattern.regex.pattern + self.pattern.regex.pattern)
return simplify_regex(self.pattern.regex.pattern)

def __get_allowed_methods__(self):
Expand Down
135 changes: 70 additions & 65 deletions rest_framework_docs/templates/rest_framework_docs/home.html
Original file line number Diff line number Diff line change
@@ -1,86 +1,91 @@
{% extends "rest_framework_docs/docs.html" %}

{% block apps_menu %}
{% regroup endpoints by name_parent as endpoints_grouped %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Jump To <span class="caret"></span></a>
<ul class="dropdown-menu">
{% for group in endpoints_grouped %}
<li><a href="#{{ group.grouper|lower }}-group">{{ group.grouper }}</a></li>
{% endfor %}
</ul>
</li>
{% regroup endpoints by labels as endpoints_grouped %}

{% if endpoints_grouped|length > 1 %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Jump To <span class="caret"></span></a>
<ul class="dropdown-menu">
{% for group in endpoints_grouped %}
<li><a href="#{{ group.grouper.name|slugify }}-group">{{ group.grouper.name }}</a></li>
{% endfor %}
</ul>
</li>
{% endif %}
{% endblock %}


{% block content %}

{% regroup endpoints by name_parent as endpoints_grouped %}
{% regroup endpoints by labels as endpoints_grouped %}

{% if endpoints_grouped %}
{% for group in endpoints_grouped %}

<h1 id="{{ group.grouper|lower }}-group">{{group.grouper}}</h1>

<div class="panel-group" role="tablist">

{% for endpoint in group.list %}

<div class="panel panel-default endpoint">

<div class="panel-heading" role="tab" data-toggle="collapse" data-target="#{{ endpoint.path|slugify }}">
<div class="row">
<div class="col-md-7">
<h4 class="panel-title title">
<i class="fa fa-link"></i> {{ endpoint.path }}
</h4>
{% for group in endpoints_grouped %}
<h1 id="{{ group.grouper.name|slugify }}-group">
{% if group.grouper.parent %}
<a href="{% url 'drfdocs-filter' group.grouper.parent %}">{{ group.grouper.name }}</a>
{% endif %}
</h1>

<div class="panel-group" role="tablist">

{% for endpoint in group.list %}

<div class="panel panel-default endpoint">

<div class="panel-heading" role="tab" data-toggle="collapse" data-target="#{{ endpoint.path|slugify }}">
<div class="row">
<div class="col-md-7">
<h4 class="panel-title title">
<i class="fa fa-link"></i> {{ endpoint.path }}
</h4>
</div>

<div class="col-md-5">
<ul class="list-inline methods">
{% for method in endpoint.allowed_methods %}
<li class="method {{ method|lower }}">{{ method }}</li>
{% endfor %}
<li class="method plug"
data-toggle="modal"
data-path="{{ endpoint.path }}"
data-methods="{{ endpoint.allowed_methods }}"
data-permissions="{{ endpoint.permissions }}"
data-fields="{{ endpoint.fields_json }}">
<i class="fa fa-plug"></i></li>
</ul>
</div>
</div>
</div>

<div class="col-md-5">
<ul class="list-inline methods">
{% for method in endpoint.allowed_methods %}
<li class="method {{ method|lower }}">{{ method }}</li>
<div id="{{ endpoint.path|slugify }}" class="panel-collapse collapse" role="tabpanel">
<div class="panel-body">
{% if endpoint.docstring %}
<p class="lead">{{ endpoint.docstring }}</p>
{% endif %}

{% if endpoint.errors %}
<div class="alert alert-danger" role="alert">Oops! There was something wrong with {{ endpoint.errors }}. Please check your code.</div>
{% endif %}

{% if endpoint.fields %}
<p class="fields-desc">Fields:</p>
<ul class="list fields">
{% for field in endpoint.fields %}
<li class="field">{{ field.name }}: {{ field.type }} {% if field.required %}<span class="label label-primary label-required" title="Required">R</span>{% endif %}</li>
{% endfor %}
<li class="method plug"
data-toggle="modal"
data-path="{{ endpoint.path }}"
data-methods="{{ endpoint.allowed_methods }}"
data-permissions="{{ endpoint.permissions }}"
data-fields="{{ endpoint.fields_json }}">
<i class="fa fa-plug"></i></li>
</ul>
</ul>
{% elif not endpoint.errors %}
<p>No fields.</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}

<div id="{{ endpoint.path|slugify }}" class="panel-collapse collapse" role="tabpanel">
<div class="panel-body">
{% if endpoint.docstring %}
<p class="lead">{{ endpoint.docstring }}</p>
{% endif %}

{% if endpoint.errors %}
<div class="alert alert-danger" role="alert">Oops! There was something wrong with {{ endpoint.errors }}. Please check your code.</div>
{% endif %}

{% if endpoint.fields %}
<p class="fields-desc">Fields:</p>
<ul class="list fields">
{% for field in endpoint.fields %}
<li class="field">{{ field.name }}: {{ field.type }} {% if field.required %}<span class="label label-primary label-required" title="Required">R</span>{% endif %}</li>
{% endfor %}
</ul>
{% elif not endpoint.errors %}
<p>No fields.</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}

</div>

{% endfor %}
{% endfor %}
{% elif not query %}
<h2 class="text-center">There are currently no api endpoints to document.</h2>
{% else %}
Expand Down
3 changes: 3 additions & 0 deletions rest_framework_docs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
urlpatterns = [
# Url to view the API Docs
url(r'^$', DRFDocsView.as_view(), name='drfdocs'),

# Url to view the API Docs with a specific namespace or app_name
url(r'^(?P<filter_app>[\w-]+)/$', DRFDocsView.as_view(), name='drfdocs-filter'),
]
17 changes: 11 additions & 6 deletions rest_framework_docs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@ class DRFDocsView(TemplateView):

def get_context_data(self, **kwargs):
settings = DRFSettings().settings
search_query = self.request.GET.get("search", "")
filter_app = self.kwargs.get("filter_app", None)

if settings["HIDE_DOCS"]:
raise Http404("Django Rest Framework Docs are hidden. Check your settings.")

context = super(DRFDocsView, self).get_context_data(**kwargs)
docs = ApiDocumentation()
docs = ApiDocumentation(filter_app=filter_app)
endpoints = docs.get_endpoints()

query = self.request.GET.get("search", "")
if query and endpoints:
endpoints = [endpoint for endpoint in endpoints if query in endpoint.path]
if filter_app and not endpoints:
raise Http404("The are no endpoints for \"%s\"." % filter_app)

context['query'] = query
if search_query and endpoints:
endpoints = [endpoint for endpoint in endpoints if search_query in endpoint.path]

context = super(DRFDocsView, self).get_context_data(**kwargs)
context['query'] = search_query
context['endpoints'] = endpoints
return context
1 change: 1 addition & 0 deletions runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def run_tests_coverage():
cov.report()
cov.html_report(directory='covhtml')


exit_on_failure(flake8_main(FLAKE8_ARGS))
exit_on_failure(run_tests_eslint())
exit_on_failure(run_tests_coverage())
83 changes: 74 additions & 9 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_settings_module(self):
self.assertEqual(settings.get_setting("HIDE_DOCS"), False)
self.assertEqual(settings.get_setting("TEST"), None)

def test_index_view_with_endpoints(self):
def test_docs_home_view_with_endpoints(self):
"""
Should load the drf focs view with all the endpoints.
NOTE: Views that do **not** inherit from DRF's "APIView" are not included.
Expand All @@ -30,16 +30,16 @@ def test_index_view_with_endpoints(self):
self.assertEqual(len(response.context["endpoints"]), 10)

# Test the login view
self.assertEqual(response.context["endpoints"][0].name_parent, "accounts")
self.assertEqual(response.context["endpoints"][0].allowed_methods, ['POST', 'OPTIONS'])
self.assertEqual(response.context["endpoints"][0].path, "/accounts/login/")
self.assertEqual(response.context["endpoints"][0].docstring, "A view that allows users to login providing their username and password.")
self.assertEqual(len(response.context["endpoints"][0].fields), 2)
self.assertEqual(response.context["endpoints"][0].fields[0]["type"], "CharField")
self.assertTrue(response.context["endpoints"][0].fields[0]["required"])
self.assertEqual(response.context["endpoints"][1].name_parent, "accounts")
self.assertEqual(response.context["endpoints"][1].allowed_methods, ['POST', 'OPTIONS'])
self.assertEqual(response.context["endpoints"][1].path, "/accounts/login/")
self.assertEqual(response.context["endpoints"][1].docstring, "A view that allows users to login providing their username and password.")
self.assertEqual(len(response.context["endpoints"][1].fields), 2)
self.assertEqual(response.context["endpoints"][1].fields[0]["type"], "CharField")
self.assertTrue(response.context["endpoints"][1].fields[0]["required"])

# The view "OrganisationErroredView" (organisations/(?P<slug>[\w-]+)/errored/) should contain an error.
self.assertEqual(str(response.context["endpoints"][8].errors), "'test_value'")
self.assertEqual(str(response.context["endpoints"][9].errors), "'test_value'")

def test_index_search_with_endpoints(self):
response = self.client.get("%s?search=reset-password" % reverse("drfdocs"))
Expand All @@ -59,3 +59,68 @@ def test_index_view_docs_hidden(self):

self.assertEqual(response.status_code, 404)
self.assertEqual(response.reason_phrase.upper(), "NOT FOUND")

def test_index_view_with_existent_namespace(self):
"""
Should load the drf docs view with all the endpoints contained in the specified namespace.
NOTE: Views that do **not** inherit from DRF's "APIView" are not included.
"""
# Test 'accounts' namespace
response = self.client.get(reverse('drfdocs-filter', args=['accounts']))
# response = self.client.get(reverse('drfdocs-filter', kwargs={'parent_app': 'accountss'}))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["endpoints"]), 5)

# Test the login view
self.assertEqual(response.context["endpoints"][0].name_parent, "accounts")
self.assertEqual(response.context["endpoints"][0].allowed_methods, ['POST', 'OPTIONS'])
self.assertEqual(response.context["endpoints"][0].path, "/accounts/login/")

# Test 'organisations' namespace
response = self.client.get(reverse('drfdocs-filter', args=['organisations']))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["endpoints"]), 3)

# The view "OrganisationErroredView" (organisations/(?P<slug>[\w-]+)/errored/) should contain an error.
self.assertEqual(str(response.context["endpoints"][2].errors), "'test_value'")

# Test 'members' namespace
response = self.client.get(reverse('drfdocs-filter', args=['members']))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["endpoints"]), 1)

def test_index_search_with_existent_namespace(self):
response = self.client.get("%s?search=reset-password" % reverse('drfdocs-filter', args=['accounts']))

self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["endpoints"]), 2)
self.assertEqual(response.context["endpoints"][1].path, "/accounts/reset-password/confirm/")
self.assertEqual(len(response.context["endpoints"][1].fields), 3)

def test_index_view_with_existent_app_name(self):
"""
Should load the drf docs view with all the endpoints contained in the specified app_name.
NOTE: Views that do **not** inherit from DRF's "APIView" are not included.
"""
# Test 'organisations_app' app_name
response = self.client.get(reverse('drfdocs-filter', args=['organisations_app']))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["endpoints"]), 4)
parents_name = [e.name_parent for e in response.context["endpoints"]]
self.assertEquals(parents_name.count('organisations'), 3)
self.assertEquals(parents_name.count('members'), 1)

def test_index_search_with_existent_app_name(self):
response = self.client.get("%s?search=create" % reverse('drfdocs-filter', args=['organisations_app']))

self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["endpoints"]), 1)
self.assertEqual(response.context["endpoints"][0].path, "/organisations/create/")
self.assertEqual(len(response.context["endpoints"][0].fields), 2)

def test_index_view_with_non_existent_namespace_or_app_name(self):
"""
Should raise a 404 if there is no such app name like the param.
"""
response = self.client.get(reverse('drfdocs-filter', args=['non-existent-ns-or-app-name']))
self.assertEqual(response.status_code, 404)
Loading