Browse Source

Shiny new REST API with Documentation and Unit tests

Gu1 11 years ago
parent
commit
42e52d051f

+ 2 - 0
ffdnispdb/__init__.py

@@ -42,7 +42,9 @@ def create_app(config={}):
     mail.init_app(app)
     mail.init_app(app)
 
 
     from .views import ispdb
     from .views import ispdb
+    from .views_api import ispdbapi
     app.register_blueprint(ispdb)
     app.register_blueprint(ispdb)
+    app.register_blueprint(ispdbapi, url_prefix='/api')
     return app
     return app
 
 
 
 

+ 131 - 0
ffdnispdb/static/css/highlight_github.css

@@ -0,0 +1,131 @@
+/*
+
+github.com style (c) Vasily Polovnyov <vast@whiteants.net>
+
+*/
+
+pre code {
+  font-size: 12px;
+  display: block;
+  color: #333;
+  background: #f5f5f5
+}
+
+pre .comment,
+pre .template_comment,
+pre .diff .header,
+pre .javadoc {
+  color: #998;
+  font-style: italic
+}
+
+pre .keyword,
+pre .css .rule .keyword,
+pre .winutils,
+pre .javascript .title,
+pre .nginx .title,
+pre .subst,
+pre .request,
+pre .status {
+  color: #333;
+  font-weight: bold
+}
+
+pre .number,
+pre .hexcolor,
+pre .ruby .constant {
+  color: #099;
+}
+
+pre .string,
+pre .tag .value,
+pre .phpdoc,
+pre .tex .formula {
+  color: #d14
+}
+
+pre .title,
+pre .id,
+pre .coffeescript .params,
+pre .scss .preprocessor {
+  color: #900;
+  font-weight: bold
+}
+
+pre .javascript .title,
+pre .lisp .title,
+pre .clojure .title,
+pre .subst {
+  font-weight: normal
+}
+
+pre .class .title,
+pre .haskell .type,
+pre .vhdl .literal,
+pre .tex .command {
+  color: #458;
+  font-weight: bold
+}
+
+pre .tag,
+pre .tag .title,
+pre .rules .property,
+pre .django .tag .keyword {
+  color: #000080;
+  font-weight: normal
+}
+
+pre .attribute,
+pre .variable,
+pre .lisp .body {
+  color: #008080
+}
+
+pre .regexp {
+  color: #009926
+}
+
+pre .class {
+  color: #458;
+  font-weight: bold
+}
+
+pre .symbol,
+pre .ruby .symbol .string,
+pre .lisp .keyword,
+pre .tex .special,
+pre .prompt {
+  color: #990073
+}
+
+pre .built_in,
+pre .lisp .title,
+pre .clojure .built_in {
+  color: #0086b3
+}
+
+pre .preprocessor,
+pre .pragma,
+pre .pi,
+pre .doctype,
+pre .shebang,
+pre .cdata {
+  color: #999;
+  font-weight: bold
+}
+
+pre .deletion {
+  background: #fdd
+}
+
+pre .addition {
+  background: #dfd
+}
+
+pre .diff .change {
+  background: #0086b3
+}
+
+pre .chunk {
+  color: #aaa
+}

+ 40 - 0
ffdnispdb/static/css/style.css

@@ -391,6 +391,7 @@ pre#validator {
     color: #ffffff;
     color: #ffffff;
 }
 }
 
 
+
 /**
 /**
  * Schema Spec / RST
  * Schema Spec / RST
  */
  */
@@ -419,3 +420,42 @@ pre#validator {
 .rst .section > :first-child {
 .rst .section > :first-child {
     margin-top: 15px;
     margin-top: 15px;
 }
 }
+
+
+/**
+ * API
+ */
+
+.api-collection .api-doc-resource {
+    background-color: #f0f0f0;
+    margin-bottom: 5px;
+    border: 1px #e5e5e5 solid;
+    border-radius: 4px;
+}
+.api-collection .api-doc-heading {
+    background-image: linear-gradient(to bottom, #f0f0f0, #eaeaea);
+    border-radius: 4px 4px 0 0;
+    padding: 6px 0;
+}
+.api-collection .api-doc-heading .path {
+    color: #666666;
+    font-weight: bold;
+    font-size: 1.1em;
+}
+.api-collection .api-doc-body {
+    background-color: #ffffff;
+    border-radius: 0 0 4px 4px;
+}
+.api-collection .api-doc-inner {
+    border-top: 1px #e5e5e5 solid;
+    padding: 9px 15px;
+}
+.api-collection .description {
+    margin-right: 10px;
+    float: right;
+}
+.api-collection .label {
+    margin: 0 5px 0 8px;
+    cursor: pointer;
+}
+

File diff suppressed because it is too large
+ 1 - 0
ffdnispdb/static/js/highlight.pack.js


+ 302 - 3
ffdnispdb/templates/api.html

@@ -1,4 +1,303 @@
 {% extends "layout.html" %}
 {% extends "layout.html" %}
-{% block container %}
-<h3>Soon...</h3>
-{% endblock container %}
+{% block page_title %}REST API <small>v1 alpha</small>{% endblock %}
+{% block head -%}
+    {{ super() }}
+    <link rel=stylesheet type=text/css href="{{ url_for('static', filename='css/highlight_github.css') }}">
+{%- endblock %}
+{% block script -%}
+{{ super() }}
+<script type="text/javascript" src="{{ url_for('static', filename='js/highlight.pack.js') }}"></script>
+<script type="text/javascript">hljs.initHighlightingOnLoad();</script>
+{%- endblock %}
+{% block body %}
+<link rel=stylesheet type=text/css href="/static/css/highlight_github.css">
+      <p>The API lives at <code>/api/v1/</code>. The format used is JSON.</p>
+      <h3>Collections</h3>
+      <h4>ISPs</h4>
+      <div class="api-collection" id="api-collection-isp">
+        <div class="api-doc-resource">
+          <div class="api-doc-heading">
+            <span class="label label-info" data-toggle="collapse" href="#api-isp-1">GET</span>
+            <a class="path" data-toggle="collapse" href="#api-isp-1">/isp/</a>
+            <a class="description" data-toggle="collapse" href="#api-isp-1">
+              returns all ISPs in database
+            </a>
+          </div>
+          <div id="api-isp-1" class="api-doc-body collapse">
+            <div class="api-doc-inner">
+              <h5>Parameters</h5>
+              <table class="table table-bordered table-striped">
+                <thead>
+                  <tr>
+                    <th style="width: 100px;">Name</th>
+                    <th>Value</th>
+                    <th>Description</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td><code>page</code></td>
+                    <td>int</td>
+                    <td>Page number</td>
+                  </tr>
+                  <tr>
+                    <td><code>per_page</code></td>
+                    <td>int</td>
+                    <td>Number of items per page</td>
+                  </tr>
+                  <tr>
+                    <td><code>range</code></td>
+                    <td>int,int</td>
+                    <td>Range to return, in the format offset,value</td>
+                  </tr>
+                </tbody>
+              </table>
+              <h5>Output</h5>
+              <p>This resource returns an array of ISP objects.</p>
+              <h5>Sample output</h5>
+              <p>When using pagination (default)&thinsp;:</p>
+              <pre><code>{
+    "total_items": 3, 
+    "page": 1, 
+    "num_pages": 1, 
+    "per_page": 10, 
+    "isps": [
+        /* list of ISP objects */
+    ]
+}</code></pre>
+              <p>When providing a range parameter&thinsp;:</p>
+              <pre><code>{
+    "total_items": 3, 
+    "range": "0,1", 
+    "isps": [
+        /* list of ISP objects */
+    ]
+}</code></pre>
+              <h5>Status code</h5>
+              <p>This resource can return the following status codes: <code>400</code>, <code>500</code>.</p>
+            </div>
+          </div>
+        </div>
+        <div class="api-doc-resource">
+          <div class="api-doc-heading">
+            <span class="label label-info" data-toggle="collapse" href="#api-isp-2">GET</span>
+            <a class="path" data-toggle="collapse" href="#api-isp-2">/isp/&lt;int:isp_id&gt;/</a>
+            <a class="description" data-toggle="collapse" href="#api-isp-2">
+              returns the ISP with id &lt;isp_id&gt;
+            </a>
+          </div>
+          <div id="api-isp-2" class="api-doc-body collapse">
+            <div class="api-doc-inner">
+              <h5>Output</h5>
+              <p>This resource returns an ISP object or an ObjectNotFound error.</p>
+              <h5>Status code</h5>
+              <p>This resource can return the following status codes: <code>404</code>, <code>400</code>, <code>500</code>.</p>
+            </div>
+          </div>
+        </div>
+        <div class="api-doc-resource">
+          <div class="api-doc-heading">
+            <span class="label label-info" data-toggle="collapse" href="#api-isp-3">GET</span>
+            <a class="path" data-toggle="collapse" href="#api-isp-3">/isp/&lt;int:isp_id&gt;/covered_areas/</a>
+            <a class="description" data-toggle="collapse" href="#api-isp-3">
+              returns the covered areas for ISP with id &lt;isp_id&gt;
+            </a>
+          </div>
+          <div id="api-isp-3" class="api-doc-body collapse">
+            <div class="api-doc-inner">
+              <h5>Parameters</h5>
+              <table class="table table-bordered table-striped">
+                <thead>
+                  <tr>
+                    <th style="width: 100px;">Name</th>
+                    <th>Value</th>
+                    <th>Description</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td><code>page</code></td>
+                    <td>int</td>
+                    <td>Page number</td>
+                  </tr>
+                  <tr>
+                    <td><code>per_page</code></td>
+                    <td>int</td>
+                    <td>Number of items per page</td>
+                  </tr>
+                  <tr>
+                    <td><code>range</code></td>
+                    <td>int,int</td>
+                    <td>Range to return, in the format offset,value</td>
+                  </tr>
+                </tbody>
+              </table>
+              <h5>Output</h5>
+              <p>This resource returns an array of CoveredArea objects belonging to a given ISP. Note that the CoveredArea objects don't have an "isp" attribute since we assume the client will have retrieved informations about the ISP beforehand.</p>
+              <h5>Status code</h5>
+              <p>This resource can return the following status codes: <code>404</code>, <code>400</code>, <code>500</code>.</p>
+            </div>
+          </div>
+        </div>
+        <div class="api-doc-resource">
+          <div class="api-doc-heading">
+            <span class="label label-info" data-toggle="collapse" href="#api-isp-4">GET</span>
+            <a class="path" data-toggle="collapse" href="#api-isp-4">/isp/export_urls/</a>
+            <a class="description" data-toggle="collapse" href="#api-isp-4">
+              returns all isp-format urls
+            </a>
+          </div>
+          <div id="api-isp-4" class="api-doc-body collapse">
+            <div class="api-doc-inner">
+              <h5>Output</h5>
+              <p>This resource returns a list of all ISP Format URLs in database.</p>
+              <h5>Status code</h5>
+              <p>This resource can return the following status codes: <code>500</code>.</p>
+            </div>
+          </div>
+        </div>
+      </div>
+      <hr style="margin: 15px 0 0 0;" />
+      <h4>Covered Areas<small>&thinsp;: Areas covered by ISPs</small></h4>
+      <div class="api-collection" id="api-collection-isp">
+        <div class="api-doc-resource">
+          <div class="api-doc-heading">
+            <span class="label label-info" data-toggle="collapse" href="#api-area-1">GET</span>
+            <a class="path" data-toggle="collapse" href="#api-area-1">/covered_area/</a>
+            <a class="description" data-toggle="collapse" href="#api-area-1">
+              returns all Covered Areas in database
+            </a>
+          </div>
+          <div id="api-area-1" class="api-doc-body collapse">
+            <div class="api-doc-inner">
+              <h5>Parameters</h5>
+              <table class="table table-bordered table-striped">
+                <thead>
+                  <tr>
+                    <th style="width: 100px;">Name</th>
+                    <th>Value</th>
+                    <th>Description</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td><code>page</code></td>
+                    <td>int</td>
+                    <td>Page number</td>
+                  </tr>
+                  <tr>
+                    <td><code>per_page</code></td>
+                    <td>int</td>
+                    <td>Number of items per page</td>
+                  </tr>
+                  <tr>
+                    <td><code>range</code></td>
+                    <td>int,int</td>
+                    <td>Range to return, in the format offset,value</td>
+                  </tr>
+                </tbody>
+              </table>
+              <h5>Output</h5>
+              <p>This resource returns an array of CoveredArea objects.</p>
+              <h5>Sample output</h5>
+              <p>When using pagination (default)&thinsp;:</p>
+              <pre><code>{
+    "total_items": 3, 
+    "page": 1, 
+    "num_pages": 1, 
+    "per_page": 10, 
+    "covered_areas": [
+        /* list of CoveredArea objects */
+    ]
+}</code></pre>
+              <p>When providing a range parameter&thinsp;:</p>
+              <pre><code>{
+    "total_items": 3, 
+    "range": "0,1", 
+    "covered_areas": [
+        /* list of CoveredArea objects */
+    ]
+}</code></pre>
+              <h5>Status code</h5>
+              <p>This resource can return the following status codes: <code>404</code>, <code>400</code>, <code>500</code>.</p>
+            </div>
+          </div>
+        </div>
+        <div class="api-doc-resource">
+          <div class="api-doc-heading">
+            <span class="label label-info" data-toggle="collapse" href="#api-area-2">GET</span>
+            <a class="path" data-toggle="collapse" href="#api-area-2">/covered_area/&lt;int:area_id&gt;/</a>
+            <a class="description" data-toggle="collapse" href="#api-area-2">
+              returns the Area with id &lt;area_id&gt;
+            </a>
+          </div>
+          <div id="api-area-2" class="api-doc-body collapse">
+            <div class="api-doc-inner">
+              <h5>Output</h5>
+              <p>This resource returns a CoveredArea object or an ObjectNotFound error.</p>
+              <h5>Status code</h5>
+              <p>This resource can return the following status codes: <code>404</code>, <code>400</code>, <code>500</code>.</p>
+            </div>
+          </div>
+        </div>
+      </div>
+      <h3 style="margin-top: 30px;">Objects</h3>
+      <h4>ISP</h4>
+      <pre><code>{
+    "id": "int",
+    "is_ffdn_member": "bool",
+    "json_url": "string:url",
+    "date_added": "string:ISO8601 datetime",
+    "last_update": "string:ISO8601 datetime",
+    "ispformat": "object:ISP Format"
+}</code></pre>
+      <h4>CoveredArea</h4>
+      <pre><code>{
+    "id": "int",
+    "isp": {
+        "id": "int",
+        "name": "string",
+        "shortname": "string|null"
+    },
+    "name": "string",
+    "geojson": "object:GeoJSON"
+}</code></pre>
+      <h3 style="margin-top: 30px;">Errors</h3>
+      <p>A typical error response looks like this:</p>
+      <pre><code>{
+    "error": {
+        "message": "There was an error while processing your request", 
+        "error_type": "ispdb.api.InternalError"
+    }
+}</code></pre>
+      <p>Message is an informative message regarding the error, error_type is an optionnal field containing the error class, which can give machine-readable informations about the error.</p>
+      <p>For now, the following error class are defined:</p>
+      <div class="row">
+        <table class="table table-bordered table-striped span10" style="background-color: #fff">
+          <thead>
+            <tr>
+              <th style="width: 200px;">Error class</th>
+              <th style="width: 100px;">Status code</th>
+              <th>Description</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td><code>ispdb.api.ObjectNotFound</code></td>
+              <td>404</td>
+              <td>Could not find the item matching the criterias you specified</td>
+            </tr>
+            <tr>
+              <td><code>ispdb.api.BadInput</code></td>
+              <td>400</td>
+              <td>Invalid input</td>
+            </tr>
+            <tr>
+              <td><code>ispdb.api.InternalError</code></td>
+              <td>500</td>
+              <td>Internal server error</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+{%- endblock %}

+ 1 - 1
ffdnispdb/views.py

@@ -387,7 +387,7 @@ def format():
     return render_template('format_spec.html', spec=Markup(parts['html_body']))
     return render_template('format_spec.html', spec=Markup(parts['html_body']))
 
 
 
 
-@ispdb.route('/api', methods=['GET'])
+@ispdb.route('/api/v1/', methods=['GET'])
 def api():
 def api():
     return render_template('api.html')
     return render_template('api.html')
 
 

+ 293 - 0
ffdnispdb/views_api.py

@@ -0,0 +1,293 @@
+# -*- coding: utf-8 -*-
+
+from flask import Blueprint, make_response, request, Response, current_app
+from flask.views import MethodView
+from collections import OrderedDict
+import sys
+import json
+import datetime
+
+from . import utils, db
+from .models import ISP, CoveredArea
+
+
+ispdbapi = Blueprint('ispdbapi', __name__)
+
+
+def output_json(data, code, headers=None):
+    """Makes a Flask response with a JSON encoded body"""
+    def encode(obj):
+        if isinstance(obj, datetime.datetime):
+            return obj.isoformat()
+
+        if hasattr(obj, '__json__'):
+            return obj.__json__()
+
+    indent = 4 if not request.is_xhr else None
+    dumped = json.dumps(data, indent=indent, default=encode)
+    dumped += '\n'
+
+    resp = make_response(dumped, code)
+    resp.headers.extend(headers or {})
+    return resp
+
+
+class REST(object):
+    DEFAULT_MIMETYPE = 'application/json'
+
+    OUTPUT_MIMETYPES = {
+        'application/json': output_json
+    }
+
+    @classmethod
+    def accepted_mimetypes(cls, default_mime=DEFAULT_MIMETYPE):
+        am=[m for m, q in request.accept_mimetypes]
+        if default_mime:
+            am+=[default_mime]
+        return am
+
+    @classmethod
+    def match_mimetype(cls):
+        for accepted_mime in cls.accepted_mimetypes():
+            if accepted_mime in cls.OUTPUT_MIMETYPES:
+                return accepted_mime, cls.OUTPUT_MIMETYPES[accepted_mime]
+
+    @classmethod
+    def negociated_resp(cls, data, code, headers={}):
+        output_mime, output_func = cls.match_mimetype()
+        resp = output_func(data, code, headers)
+        resp.headers['Content-Type'] = output_mime
+        return resp
+
+    @classmethod
+    def marsh_error(cls, error):
+        return cls.negociated_resp({
+            'error': dict(error)
+        }, error.status_code)
+
+
+class RESTException(Exception):
+    def __init__(self, status_code, msg, error_type=None):
+        super(RESTException, self).__init__()
+        self.status_code = status_code
+        self.message = msg
+        self.error_type = error_type
+
+    def __iter__(self):
+        return {
+            'error_type': self.error_type,
+            'message': self.message
+        }.iteritems()
+
+    def __json__(self):
+        return {
+            'error': dict(self)
+        }
+
+
+class RESTSimpleError(RESTException):
+    def __init__(self):
+        pass
+
+
+class ObjectNotFound(RESTSimpleError):
+    status_code = 404
+    message = 'Object not found'
+    error_type = 'ispdb.api.ObjectNotFound'
+
+
+class InternalError(RESTSimpleError):
+    status_code = 500
+    message = 'There was an error while processing your request'
+    error_type = 'ispdb.api.InternalError'
+
+
+class Resource(MethodView, REST):
+
+    def __init__(self, *args, **kwargs):
+        super(Resource, self).__init__(*args, **kwargs)
+
+    def dispatch_request(self, *args, **kwargs):
+        meth = getattr(self, request.method.lower(), None)
+        resp = meth(*args, **kwargs)
+        if isinstance(resp, Response):
+            return resp
+
+        data, code, headers = (None,)*3
+        if isinstance(resp, tuple):
+            data, code, headers = resp + (None,) * (3 - len(resp))
+        data = resp if data is None else data
+        code = 200 if code is None else code
+        headers = {} if headers is None else headers
+        resp = self.negociated_resp(data, code, headers)
+        return resp
+
+    def get_range(self):
+        range_ = request.args.get('range')
+        if not range_:
+            return None
+        try:
+            range_ = map(int, filter(None, range_.split(',', 1)))
+            return range_
+        except ValueError:
+            return None
+
+    def apply_range(self, query, range_):
+        return query.slice(*range_) if len(range_) > 1 else query.offset(range_[0])
+
+    def handle_list(self, query, cb, paginate=10, out_var=None):
+        res = OrderedDict()
+        res['total_items'] = query.count()
+
+        range_ = self.get_range()
+        if range_:
+            query = self.apply_range(query, range_)
+            items = [cb(o) for o in query]
+            res['range'] = ','.join(map(str, range_))
+        elif paginate:
+            page = request.args.get('page', 1)
+            per_page = request.args.get('per_page', paginate)
+            try:
+                page = int(page)
+            except ValueError:
+                page = 1
+            try:
+                per_page = int(per_page)
+            except ValueError:
+                per_page = paginate
+            pgn = query.paginate(page, per_page=per_page, error_out=False)
+            items = [cb(o) for o in pgn.items]
+            res['page'] = pgn.page
+            res['num_pages'] = pgn.pages
+            res['per_page'] = pgn.per_page
+
+        if out_var is None:
+            out_var = query.column_descriptions[0]['name'].lower()+'s'
+        res[out_var] = items
+        return res
+
+
+class ISPResource(Resource):
+    """
+    /isp/
+        GET - list all ISPs
+
+    /isp/<int:isp_id>/
+        GET - return ISP with the given id
+    """
+    def isp_to_dict(self, isp):
+        r=OrderedDict()
+        r['id'] = isp.id
+        r['is_ffdn_member'] = isp.is_ffdn_member
+        r['json_url'] = isp.json_url
+        r['date_added'] = utils.tosystemtz(isp.date_added)
+        if isp.last_update_success:
+            r['last_update'] = utils.tosystemtz(isp.last_update_success)
+        else:
+            r['last_update'] = None
+        r['ispformat'] = isp.json
+        return r
+
+    def get(self, isp_id=None):
+        if isp_id is not None:
+            s = ISP.query.filter_by(id=isp_id, is_disabled=False).scalar()
+            if not s:
+                raise ObjectNotFound
+            return self.isp_to_dict(s)
+        else:
+            s = ISP.query.filter_by(is_disabled=False)
+            return self.handle_list(s, self.isp_to_dict)
+
+
+class CoveredAreaResource(Resource):
+    """
+    /covered_area/
+        GET - list all covered areas
+
+    /covered_area/<int:area_id>/
+        GET - return covered area with the given id
+
+    /isp/<int:isp_id>/covered_area/
+        GET - return covered areas for the given ISP
+    """
+    def ca_to_dict(self, ca):
+        r=OrderedDict()
+        r['id'] = ca.id
+        if not self.isp_id:
+            r['isp'] = OrderedDict()
+            r['isp']['id'] = ca.isp_id
+            r['isp']['name'] = ca.isp.name
+            r['isp']['shortname'] = ca.isp.shortname
+        r['name'] = ca.name
+        r['geojson'] = json.loads(ca.area_geojson) if ca.area_geojson else None
+        return r
+
+    def get(self, area_id=None, isp_id=None):
+        self.area_id = area_id
+        self.isp_id = isp_id
+        if area_id is not None:
+            raise ObjectNotFound
+            s = CoveredArea.query.get_or_404(area_id)
+            return self.ca_to_dict(s)
+        else:
+            s = CoveredArea.query.filter(ISP.is_disabled==False)\
+                                 .options(db.joinedload('isp'),
+                                          db.defer('isp.json'),
+                                          db.defer('area'),
+                                          db.undefer('area_geojson'))
+            if isp_id:
+                if not ISP.query.filter_by(id=isp_id, is_disabled=False).scalar():
+                    raise ObjectNotFound
+                s = s.filter(CoveredArea.isp_id==isp_id)
+            return self.handle_list(s, self.ca_to_dict, out_var='covered_areas')
+
+
+@ispdbapi.route('/<path:notfound>')
+def path_not_found(notfound):
+    "catch all"
+    return REST.marsh_error(RESTException(404, 'path not found', 'ispdb.api.PathNotFound'))
+
+
+@ispdbapi.errorhandler(404)
+def resource_not_found(e):
+    return REST.marsh_error(RESTException(404, 'not found'))
+
+
+@ispdbapi.errorhandler(RESTException)
+def handle_rest_error(e):
+    return REST.marsh_error(e)
+
+
+@ispdbapi.errorhandler(Exception)
+def handle_generic_exception(e):
+    "Return a REST-formated error response instead of the standard 500 html template"
+    current_app.log_exception(sys.exc_info())
+    return REST.marsh_error(InternalError())
+
+
+isp_view = ISPResource.as_view('isp_api')
+ispdbapi.add_url_rule('/v1/isp/', defaults={'isp_id': None},
+                      view_func=isp_view, methods=['GET',])
+ispdbapi.add_url_rule('/v1/isp/<int:isp_id>/', view_func=isp_view,
+                      methods=['GET'])
+
+@ispdbapi.route('/v1/isp/export_urls/')
+@ispdbapi.route('/v1/isp/all_your_urls_are_belong_to_us/')
+def all_urls():
+    """
+    This resource allows to simply export all ISP-format URLs in our DB
+    without pulling all ISP data.
+    """
+    isps = db.session.query(ISP.id, ISP.json_url).filter(ISP.json_url != None)
+    return REST.negociated_resp({
+        'isps': [{'id': isp.id, 'json_url': isp.json_url} for isp in isps]
+    }, 200)
+
+
+ca_view = CoveredAreaResource.as_view('covered_area_api')
+ispdbapi.add_url_rule('/v1/covered_area/', defaults={'area_id': None},
+                      view_func=ca_view, methods=['GET',])
+ispdbapi.add_url_rule('/v1/covered_area/<int:area_id>/', view_func=ca_view,
+                      methods=['GET'])
+ispdbapi.add_url_rule('/v1/isp/<int:isp_id>/covered_areas/', view_func=ca_view,
+                      methods=['GET'])

+ 142 - 2
test_ffdnispdb.py

@@ -1,11 +1,74 @@
 
 
-from ffdnispdb import create_app, db
+from ffdnispdb import create_app, db, utils
 from ffdnispdb.models import ISP
 from ffdnispdb.models import ISP
 from flask import Flask
 from flask import Flask
 from flask.ext.sqlalchemy import SQLAlchemy
 from flask.ext.sqlalchemy import SQLAlchemy
 import unittest
 import unittest
 import doctest
 import doctest
-import os
+import json
+
+
+
+def ISP_fixtures():
+    isp1, isp2 = ISP(), ISP()
+    isp1.name = 'Test ISP 1'
+    isp1.shortname = 'ISP1'
+    isp1.json_url = 'http://doesnt.exists/isp.json'
+    isp1.json = {
+        'name': 'Test ISP 1',
+        'shortname': 'ISP1',
+        'coveredAreas': [
+            {
+                'name': 'Some Area',
+                'technologies': ['dsl'],
+                'area': { "type": "Polygon", "coordinates": [[
+                    [ 0.889892578125, 48.32703913063476 ],
+                    [ 0.054931640625, 47.39834920035926 ],
+                    [ 0.142822265625, 46.837649560937464 ],
+                    [ 2.911376953125, 46.42271253466717 ],
+                    [ 4.39453125, 46.98774725646565 ],
+                    [ 4.4384765625, 48.52388120259336 ],
+                    [ 2.5927734375, 48.8936153614802 ],
+                    [ 0.889892578125, 48.32703913063476 ]
+                ]]}
+            },
+            {
+                'name': 'Some Other Area',
+                'technologies': ['ftth'],
+            },
+        ],
+        'version': 0.1
+    }
+    isp2.name = 'Test ISP 2'
+    isp2.shortname = 'ISP2'
+    isp2.json_url = 'http://doesnt.exists/isp.json'
+    isp2.json = {
+        'name': 'Test ISP 2',
+        'shortname': 'ISP2',
+        'coveredAreas': [
+            {
+                'name': 'Middle of nowhere',
+                'technologies': ['dsl'],
+                'area': { "type": "Polygon", "coordinates": [[
+                    [ 0.889892578125, 48.32703913063476 ],
+                    [ 0.054931640625, 47.39834920035926 ],
+                    [ 0.142822265625, 46.837649560937464 ],
+                    [ 2.911376953125, 46.42271253466717 ],
+                    [ 4.39453125, 46.98774725646565 ],
+                    [ 4.4384765625, 48.52388120259336 ],
+                    [ 2.5927734375, 48.8936153614802 ],
+                    [ 0.889892578125, 48.32703913063476 ]
+                ]]}
+            },
+            {
+                'name': 'Urban Area',
+                'technologies': ['ftth'],
+            },
+        ],
+        'version': 0.1
+    }
+    return (isp1, isp2)
+
 
 
 
 
 class TestCase(unittest.TestCase):
 class TestCase(unittest.TestCase):
@@ -55,6 +118,83 @@ class TestForm(TestCase):
         self.assertEqual(ISP.query.filter_by(name='Test').count(), 1)
         self.assertEqual(ISP.query.filter_by(name='Test').count(), 1)
 
 
 
 
+class TestAPI(TestCase):
+    def setUp(self):
+        super(TestAPI, self).setUp()
+        db.session.add_all(ISP_fixtures())
+        db.session.commit()
+
+    def check_isp_apiobj(self, isp, apiobj):
+        self.assertEqual(apiobj['is_ffdn_member'], isp.is_ffdn_member)
+        self.assertEqual(apiobj['json_url'], isp.json_url)
+        self.assertEqual(apiobj['date_added'], utils.tosystemtz(isp.date_added).isoformat())
+        self.assertEqual(apiobj['last_update'],
+                         utils.tosystemtz(isp.last_update_success).isoformat()
+                                if isp.last_update_success else None)
+        self.assertEqual(apiobj['ispformat'], isp.json)
+
+    def check_coveredarea_apiobj(self, ca, apiobj):
+        self.assertEqual(apiobj['name'], ca.name)
+        self.assertEqual(apiobj['geojson'], json.loads(ca.area_geojson)
+                                if ca.area_geojson is not None else None)
+
+    def test_isps(self):
+        c = self.client.get('/api/v1/isp/')
+        self.assertStatus(c, 200)
+        resp = json.loads(c.data)
+        isps = ISP.query.filter_by(is_disabled=False)
+        self.assertEqual(isps.count(),
+                         resp['total_items'])
+
+        for isp in isps:
+            m = filter(lambda i: i['id'] == isp.id, resp['isps'])[0]
+            self.check_isp_apiobj(isp, m)
+
+
+    def test_isp(self):
+        isps = ISP.query.filter_by(is_disabled=False).all()
+        victim = isps[0]
+        c = self.client.get('/api/v1/isp/%d/'%victim.id)
+        self.assertStatus(c, 200)
+        resp = json.loads(c.data)
+        self.check_isp_apiobj(victim, resp)
+
+        victim.is_disabled = True
+        db.session.commit()
+        c = self.client.get('/api/v1/isp/%d/'%victim.id)
+        self.assertStatus(c, 404)
+
+        victim = isps[1]
+        c = self.client.get('/api/v1/isp/%d/covered_areas/'%victim.id)
+        resp = json.loads(c.data)
+        for ca in victim.covered_areas:
+            m = filter(lambda i: i['id'] == ca.id, resp['covered_areas'])[0]
+            self.check_coveredarea_apiobj(ca, m)
+
+
+    def test_urls(self):
+        db_urls = ISP.query.filter(ISP.json_url != None).values('json_url')
+        db_urls = [u[0] for u in db_urls]
+        c = self.client.get('/api/v1/isp/export_urls/')
+        self.assertStatus(c, 200)
+        api_urls = map(lambda x: x['json_url'], json.loads(c.data)['isps'])
+        self.assertEqual(len(api_urls), len(db_urls))
+        for au in api_urls:
+            self.assertIn(au, db_urls)
+
+
+
+    def test_coveredarea(self):
+        isp = ISP.query.filter_by(is_disabled=False).first()
+        c = self.client.get('/api/v1/isp/%d/covered_areas/'%isp.id)
+        self.assertStatus(c, 200)
+        resp = json.loads(c.data)
+        for ca in isp.covered_areas:
+            m = filter(lambda i: i['id'] == ca.id, resp['covered_areas'])[0]
+            self.check_coveredarea_apiobj(ca, m)
+
+
+
 def load_tests(loader, tests, ignore):
 def load_tests(loader, tests, ignore):
     from ffdnispdb import views, models, utils, forms, crawler, sessions
     from ffdnispdb import views, models, utils, forms, crawler, sessions
     tests.addTests(doctest.DocTestSuite(views))
     tests.addTests(doctest.DocTestSuite(views))