general: automatically generate endpoints panel (#17691)

This commit is contained in:
Frédéric Péters 2017-07-19 07:21:14 +02:00
parent 8776d64bc3
commit ba46913719
17 changed files with 297 additions and 134 deletions

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2017 Entr'ouvert
#
@ -26,11 +27,20 @@ from passerelle.utils.api import endpoint
class AirQuality(BaseResource):
category = _('Misc')
api_description = _(u'''
This API provides a unique format for the air quality data of various places.
(But only supports the Rhône-Alpes region for now).
''')
class Meta:
verbose_name = _('Air Quality')
@endpoint(pattern='^(?P<country>\w+)/(?P<city>\w+)/$')
@endpoint(pattern='^(?P<country>\w+)/(?P<city>\w+)/$',
example_pattern='{country}/{city}/',
parameters={
'country': {'description': _('Country Code'), 'example_value': 'fr'},
'city': {'description': _('City Name'), 'example_value': 'lyon'},
})
def details(self, request, country, city, **kwargs):
methods = {
('fr', 'albertville'): 'air_rhonealpes',

View File

@ -1,22 +0,0 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block endpoints %}
<p>
{% blocktrans %}
This API provides a unique format for the air quality data of various places.
(But only supports the Rhône-Alpes region for now).
{% endblocktrans %}
</p>
<ul>
<li>{% trans 'Details:' %} <a href="details/fr/lyon/"
>{{ site_base_uri }}{{ object.get_absolute_url }}details/<i>country</i>/<i>city</i>/</a></li>
</ul>
{% endblock %}
{% block security %}
<p>
{% trans 'The data is open.' %}
</p>
{% endblock %}

View File

@ -31,7 +31,7 @@ class Feed(BaseResource):
class Meta:
verbose_name = _('Feed')
@endpoint(perm='can_access')
@endpoint(perm='can_access', description=_('Feed'))
def json(self, request):
response = self.requests.get(self.url)
response.raise_for_status()

View File

@ -1,12 +0,0 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block endpoints %}
<ul>
<li>
{% trans "Feed:" %}
{% url "generic-endpoint" connector="feeds" slug=object.slug endpoint="json" as endpoint_path %}
<a href="{{endpoint_path}}">{{endpoint_path}}</a>
</li>
</ul>
{% endblock %}

View File

@ -60,14 +60,18 @@ class JsonDataStore(BaseResource):
class Meta:
verbose_name = _('JSON Data Store')
@endpoint(perm='can_access', name='data', pattern=r'$')
@endpoint(perm='can_access', name='data', pattern=r'$',
description=_('Listing'))
def list(self, request, name_id=None, **kwargs):
objects = JsonData.objects.filter(datastore=self)
if name_id:
objects = objects.filter(name_id=name_id)
return {'data': [{'id': x.uuid, 'text': x.text, 'content': x.content} for x in objects]}
@endpoint(perm='can_access', methods=['post'], name='data', pattern=r'create$')
@endpoint(perm='can_access', methods=['post'], name='data',
pattern=r'create$',
example_pattern='create',
description=_('Create'))
def create(self, request, name_id=None, **kwargs):
attrs = {
'content': json.loads(request.body),
@ -88,7 +92,13 @@ class JsonDataStore(BaseResource):
attrs['name_id'] = name_id
return JsonData.objects.get(**attrs)
@endpoint(perm='can_access', methods=['get', 'post'], name='data', pattern=r'(?P<uuid>\w+)/$',)
@endpoint(perm='can_access', methods=['get', 'post'], name='data',
pattern=r'(?P<uuid>\w+)/$',
example_pattern='{uuid}/',
description_get=_('Get'),
description_post=_('Replace'),
parameters={'uuid': {'description': _('Object identifier'), 'example_value': '12345'}},
)
def get_or_replace(self, request, uuid, name_id=None):
data = self.get_data_object(uuid, name_id)
if request.method == 'POST':
@ -96,7 +106,12 @@ class JsonDataStore(BaseResource):
data.save()
return {'id': data.uuid, 'text': data.text, 'content': data.content}
@endpoint(perm='can_access', methods=['post'], name='data', pattern=r'(?P<uuid>\w+)/delete$')
@endpoint(perm='can_access', methods=['post'], name='data',
description=_('Delete'),
pattern=r'(?P<uuid>\w+)/delete$',
example_pattern='{uuid}/delete',
parameters={'uuid': {'description': _('Object identifier'), 'example_value': '12345'}},
)
def delete(self, request, uuid, name_id=None):
self.get_data_object(uuid, name_id).delete()
return {}

View File

@ -1,18 +0,0 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block endpoints %}
<ul>
<li>{% trans 'Listing:' %} <a href="data/"
>{{ site_base_uri }}{{ object.get_absolute_url }}data/</a></li>
<li>{% trans 'Create:' %} <a href="data/create"
>{{ site_base_uri }}{{ object.get_absolute_url }}data/create</a> (POST)</li>
<li>{% trans 'Get:' %} <a href="data/uuid/"
>{{ site_base_uri }}{{ object.get_absolute_url }}data/<i>uuid</i/>/</a></li>
<li>{% trans 'Replace:' %} <a href="data/uuid/"
>{{ site_base_uri }}{{ object.get_absolute_url }}data/<i>uuid</i/>/</a> (POST)</li>
<li>{% trans 'Delete:' %} <a href="data/uuid/delete"
>{{ site_base_uri }}{{ object.get_absolute_url }}data/<i>uuid</i/>/delete</a> (POST)</li>
</ul>
{% endblock %}

View File

@ -48,7 +48,12 @@ class OpenGIS(BaseResource):
def get_icon_class(cls):
return 'gis'
@endpoint(perm='can_access')
@endpoint(perm='can_access',
description=_('Get feature info'),
parameters={
'lat': {'description': _('Latitude'), 'example_value': '45.79689'},
'lon': {'description': _('Longitude'), 'example_value': '4.78414'},
})
def feature_info(self, request, lat, lon):
bbox = '%s,%s,%s,%s' % (lat, lon, float(lat) + 0.002, float(lon) + 0.002)
params = {

View File

@ -1,13 +0,0 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block endpoints %}
<ul>
<li>
{% trans "Get feature info:" %}
{% url "generic-endpoint" connector="opengis" slug=object.slug endpoint="feature_info" as endpoint_path %}
<a href="{{endpoint_path}}?lat=45.796890&lon=4.784140"
>{{endpoint_path}}?lat=45.796890&lon=4.784140</a>
</li>
</ul>
{% endblock %}

View File

@ -1,3 +1,4 @@
import copy
import inspect
import logging
import os
@ -198,21 +199,25 @@ class BaseResource(models.Model):
fields.append((field, value))
return fields
@classmethod
def get_endpoints(cls):
def get_endpoints_infos(self):
endpoints = []
for name, method in inspect.getmembers(cls, predicate=inspect.ismethod):
for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
if hasattr(method, 'endpoint_info'):
endpoints.append((name, method))
method.endpoint_info.object = self
for http_method in method.endpoint_info.methods:
# duplicate information to give each method its own entry
endpoint_info = copy.copy(method.endpoint_info)
endpoint_info.http_method = http_method
endpoints.append(endpoint_info)
endpoints.sort(key=lambda x: (x.name, x.pattern))
return endpoints
@classmethod
def get_connector_permissions(cls):
def get_connector_permissions(self):
perms = {}
for endpoint in cls.get_endpoints():
permission = endpoint[1].endpoint_info.perm
for endpoint_info in self.get_endpoints_infos():
permission = endpoint_info.perm
if permission:
perms[permission] = getattr(cls,
perms[permission] = getattr(self,
'_%s_description' % permission,
_('Access (%s) is limited to the following API users:') % permission)
return [{'key': x[0], 'label': x[1]} for x in perms.items()]

View File

@ -63,25 +63,49 @@ class StubInvoicesConnector(BaseResource):
def get_invoice(self, invoice_id):
return self.invoices.get(invoice_id)
@endpoint(name='invoices', pattern='^history/$')
@endpoint(name='invoices', pattern='^history/$',
description=_('Get list of paid invoices'),
example_pattern='history/')
def invoices_history(self, request, NameID=None, **kwargs):
return {'data': [x for x in self.get_invoices() if x.get('payment_date')]}
@endpoint(name='invoices')
@endpoint(name='invoices',
description=_('Get list of unpaid invoices'))
def invoices_list(self, request, NameID=None, **kwargs):
return {'data': [x for x in self.get_invoices() if not x.get('payment_date')]}
@endpoint(name='invoice', pattern='^(?P<invoice_id>\w+)/?$')
@endpoint(name='invoice', pattern='^(?P<invoice_id>\w+)/?$',
description=_('Get invoice details'),
example_pattern='{invoice_id}/',
parameters={
'invoice_id': {
'description': _('Invoice identifier'),
'example_value': invoices.keys()[0],
}})
def invoice(self, request, invoice_id, NameID=None, **kwargs):
return {'data': self.get_invoice(invoice_id)}
@endpoint(name='invoice', pattern='^(?P<invoice_id>\w+)/pdf/?$')
@endpoint(name='invoice', pattern='^(?P<invoice_id>\w+)/pdf/?$',
description=_('Get invoice as a PDF file'),
example_pattern='{invoice_id}/pdf/',
parameters={
'invoice_id': {
'description': _('Invoice identifier'),
'example_value': invoices.keys()[0],
}})
def invoice_pdf(self, request, invoice_id, NameID=None, **kwargs):
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="%s.pdf"' % invoice_id
response.write('')
return response
@endpoint(name='invoice', perm='can_access', methods=['post'], pattern='^(?P<invoice_id>\w+)/pay/?$')
@endpoint(name='invoice', perm='can_access', methods=['post'], pattern='^(?P<invoice_id>\w+)/pay/?$',
description=_('Pay invoice'),
example_pattern='{invoice_id}/pay/',
parameters={
'invoice_id': {
'description': _('Invoice identifier'),
'example_value': invoices.keys()[0],
}})
def invoice_pay(self, request, invoice_id, NameID=None, **kwargs):
return {'data': None}

View File

@ -1,31 +0,0 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block description %}
{% endblock %}
{% block endpoints %}
<ul>
<li>{% trans 'Get invoice history list:' %}
{% url 'generic-endpoint' connector='stub-invoices' slug=object.slug endpoint='invoices' rest='history/' as invoices_history_url %}
<a href="{{ invoices_history_url }}">{{ site_base_uri }}{{ invoices_history_url }}?NameID=...</a>
</li>
<li>{% trans 'Get invoice list:' %}
{% url 'generic-endpoint' connector='stub-invoices' slug=object.slug endpoint='invoices' rest='' as invoices_url %}
<a href="{{ invoices_url }}">{{ site_base_uri }}{{ invoices_url }}?NameID=...</a>
</li>
<li>{% trans 'Show invoice details:' %}
{% url 'generic-endpoint' connector='stub-invoices' slug=object.slug endpoint='invoice' rest='20150916/' as invoice_details_url %}
<a href="{{ invoice_details_url }}">{{ site_base_uri }}{{ invoice_details_url }}</a>
<em>20150916</em> {% trans 'is invoice identifier' %}
</li>
<li>{% trans 'Get invoice pdf:' %}
{% url 'generic-endpoint' connector='stub-invoices' slug=object.slug endpoint='invoice' rest='20150916/pdf/' as invoice_download_url %}
<a href="{{ invoice_download_url }}">{{ site_base_uri }}{{ invoice_download_url }}</a>
</li>
<li>{% trans 'Pay invoice:' %}
{% url 'generic-endpoint' connector='stub-invoices' slug=object.slug endpoint='invoice' rest='20150916/pay/' as payment_url %}
<a href="{{ payment_url }}">{{ site_base_uri }}{{ payment_url }}?NameID=...</a>
</li>
</ul>
{% endblock %}

View File

@ -38,7 +38,12 @@ class Tcl(BaseResource):
class Meta:
verbose_name = _('TCL')
@endpoint(pattern='^(?P<identifier>\w+)/?$', perm='can_access')
@endpoint(pattern='^(?P<identifier>\w+)/?$', perm='can_access',
description=_('Info about a stop'),
example_pattern='{identifier}/',
parameters={
'identifier': {'description': _('Stop Identifier'), 'example_value': '30211'}
})
def stop(self, request, identifier):
stop_object = Stop.objects.get(id_data=identifier)
stop = {

View File

@ -1,14 +0,0 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block endpoints %}
<ul>
<li>
{% trans "Info about a stop:" %}
{% url "generic-endpoint" connector="tcl" slug=object.slug endpoint="stop" as endpoint_path %}
<a href="{{endpoint_path}}/30211/"
>{{endpoint_path}}/<i>id</i>/</a>
</pre>
</li>
</ul>
{% endblock %}

View File

@ -1,3 +1,9 @@
i.varname {
background: #eee;
font-family: monospace;
font-style: normal;
}
div#queries,
div#security,
div#logs,

View File

@ -33,7 +33,29 @@
<div id="endpoints">
<h3>{% trans 'Endpoints' %}</h3>
<div>
{% if object.api_description %}<p>{{object.api_description}}</p>{% endif %}
{% block endpoints %}
<ul>
{% for endpoint in object.get_endpoints_infos %}
<li>{{endpoint.description}}{% trans ':' %}
<a href="{{endpoint.example_url}}">{{ site_base_uri }}{{endpoint.example_url_as_html}}</a>
{% if endpoint.methods|length > 1 %}
({{endpoint.http_method|upper}})
{% endif %}
{% if endpoint.has_params %}
<ul class="params">
{% for param in endpoint.get_params %}
<li>{{param.name}}
{% if param.optional %}({% trans 'optional' %}{% if param.default_value %},
{% trans 'default value:' %} {{param.default_value}}{% endif %}){% endif %}
{% if param.description %}{% trans ':' %} {{param.description}}{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}
</div>
</div>

View File

@ -14,19 +14,110 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import inspect
from django.core.urlresolvers import reverse
from django.utils.safestring import mark_safe
class endpoint(object):
do_not_call_in_templates = True
def __init__(self, serializer_type='json-api', perm=None, methods=['get'], name=None, pattern=None,
wrap_response=False):
wrap_response=False,
description=None,
description_get=None,
description_post=None,
example_pattern=None,
parameters=None):
self.perm = perm
self.methods = methods
self.serializer_type = serializer_type
self.pattern = pattern
self.name = name
self.wrap_response = wrap_response
self.descriptions = {
'get': description_get or description,
'post': description_post or description,
}
self.example_pattern = example_pattern
self.parameters = parameters
def __call__(self, func):
func.endpoint_info = self
if not self.name:
self.name = func.func_name
self.func = func
return func
def get_example_params(self):
return dict([(x, self.parameters[x]['example_value']) for x in self.parameters or {}
if x in self.parameters and 'example_value' in self.parameters[x]])
def get_query_parameters(self):
query_parameters = []
for param, param_value in self.get_example_params().items():
if param in (self.example_pattern or ''):
continue
query_parameters.append((param, param_value))
return query_parameters
def example_url(self):
kwargs = {
'connector': self.object.get_connector_slug(),
'slug': self.object.slug,
'endpoint': self.name,
}
if self.example_pattern:
kwargs['rest'] = self.example_pattern.format(**self.get_example_params())
query_string = ''
query_parameters = self.get_query_parameters()
if query_parameters:
query_string = '?' + '&'.join(['%s=%s' % x for x in query_parameters])
return reverse('generic-endpoint', kwargs=kwargs) + query_string
def example_url_as_html(self):
kwargs = {
'connector': self.object.get_connector_slug(),
'slug': self.object.slug,
'endpoint': self.name,
}
if self.example_pattern:
kwargs['rest'] = self.example_pattern.format(
**dict([(x, '$%s$' % x) for x in self.get_example_params().keys()]))
url = reverse('generic-endpoint', kwargs=kwargs)
for param in self.get_example_params():
url = url.replace('$%s$' % param, '<i class="varname">%s</i>' % param)
query_string = ''
query_parameters = self.get_query_parameters()
if query_parameters:
query_string = '?' + '&'.join(['%s=<i class="varname">%s</i>' % (x[0], x[0]) for x in query_parameters])
return mark_safe(url + query_string)
def has_params(self):
argspec = inspect.getargspec(self.func)
return len(argspec.args) > 2 # (self, request)
@property
def description(self):
return self.descriptions.get(self.http_method)
def get_params(self):
params = []
defaults = dict(zip(
reversed(inspect.getargspec(self.func).args),
reversed(inspect.getargspec(self.func).defaults or [])))
for param in inspect.getargspec(self.func).args[2:]:
param_info = {'name': param}
if self.parameters and param in self.parameters and self.parameters[param].get('description'):
param_info['description'] = self.parameters[param].get('description')
if param in defaults:
param_info['optional'] = True
param_info['default_value'] = defaults[param]
params.append(param_info)
return params

View File

@ -25,9 +25,11 @@ import pytest
import utils
from passerelle.base.models import ResourceLog, ProxyLogger
from passerelle.base.models import BaseResource, ResourceLog, ProxyLogger
from passerelle.contrib.mdel.models import MDEL
from passerelle.contrib.arcgis.models import Arcgis
from passerelle.contrib.stub_invoices.models import StubInvoicesConnector
from passerelle.utils.api import endpoint
@pytest.fixture
@ -131,3 +133,91 @@ def test_proxy_logger(mocked_get, caplog, app, arcgis):
assert ResourceLog.objects.count() == 2
assert ResourceLog.objects.last().message == 'first warning'
assert ResourceLog.objects.last().levelno == 30
class FakeConnectorBase(object):
slug = 'connector'
def get_connector_slug(self):
return 'fake'
@endpoint()
def foo1(self, request):
pass
@endpoint(name='bar')
def foo2(self, request, param1):
pass
@endpoint()
def foo3(self, request, param1, param2):
pass
@endpoint()
def foo4(self, request, param1, param2='a', param3='b'):
pass
@endpoint(pattern='^test/$', example_pattern='test/')
def foo5(self, request, param1='a', param2='b', param3='c'):
pass
@endpoint(pattern='^(?P<param1>\w+)/?$',
example_pattern='{param1}/',
parameters={
'param1': {'description': 'param 1', 'example_value': 'bar'}})
def foo6(self, request, param1, param2='a'):
pass
@endpoint(description_get='foo7 get', description_post='foo7 post',
methods=['get', 'post'])
def foo7(self, request, param1='a', param2='b', param3='c'):
pass
def test_endpoint_decorator():
connector = FakeConnectorBase()
for i in range(6):
getattr(connector, 'foo%d' % (i+1)).endpoint_info.object = connector
assert connector.foo1.endpoint_info.name == 'foo1'
assert connector.foo2.endpoint_info.name == 'bar'
assert not connector.foo1.endpoint_info.has_params()
assert connector.foo2.endpoint_info.has_params()
assert connector.foo2.endpoint_info.get_params() == [{'name': 'param1'}]
assert connector.foo3.endpoint_info.get_params() == [{'name': 'param1'}, {'name': 'param2'}]
assert connector.foo4.endpoint_info.get_params() == [
{'name': 'param1'},
{'name': 'param2', 'optional': True, 'default_value': 'a'},
{'name': 'param3', 'optional': True, 'default_value': 'b'}]
assert connector.foo5.endpoint_info.get_params() == [
{'name': 'param1', 'optional': True, 'default_value': 'a'},
{'name': 'param2', 'optional': True, 'default_value': 'b'},
{'name': 'param3', 'optional': True, 'default_value': 'c'}]
assert connector.foo6.endpoint_info.get_params() == [
{'name': 'param1', 'description': 'param 1'},
{'name': 'param2', 'optional': True, 'default_value': 'a'}]
assert connector.foo1.endpoint_info.example_url() == '/fake/connector/foo1'
assert connector.foo1.endpoint_info.example_url_as_html() == '/fake/connector/foo1'
assert connector.foo2.endpoint_info.example_url() == '/fake/connector/bar'
assert connector.foo3.endpoint_info.example_url() == '/fake/connector/foo3'
assert connector.foo5.endpoint_info.example_url() == '/fake/connector/foo5/test/'
assert connector.foo5.endpoint_info.example_url_as_html() == '/fake/connector/foo5/test/'
assert connector.foo6.endpoint_info.example_url() == '/fake/connector/foo6/bar/'
assert connector.foo6.endpoint_info.example_url_as_html() == '/fake/connector/foo6/<i class="varname">param1</i>/'
connector.foo6.endpoint_info.pattern = None
connector.foo6.endpoint_info.example_pattern = None
assert connector.foo6.endpoint_info.example_url() == '/fake/connector/foo6?param1=bar'
assert connector.foo6.endpoint_info.example_url_as_html() == '/fake/connector/foo6?param1=<i class="varname">param1</i>'
connector.foo7.endpoint_info.http_method = 'get'
assert connector.foo7.endpoint_info.description == 'foo7 get'
connector.foo7.endpoint_info.http_method = 'post'
assert connector.foo7.endpoint_info.description == 'foo7 post'
def test_endpoint_description_in_template(app, db):
StubInvoicesConnector(slug='fake').save()
resp = app.get('/stub-invoices/fake/')
assert 'Get invoice details' in resp.text