From a87309e5ddda960149e53c4aaad6c3870bd22045 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Mon, 8 Jan 2018 09:17:35 +0100
Subject: [PATCH] Add pagination to API

Pagination information (next, prev, first, last) is included in the Link
HTTP header.
The total number of entries is provided in the X-Total-Count HTTP
header.
---
 app/api/inventory.py | 13 ++++++-----
 app/api/network.py   | 13 +++++------
 app/api/user.py      |  2 +-
 app/api/utils.py     | 51 ++++++++++++++++++++++++++++++++++++++------
 app/utils.py         |  7 +++---
 5 files changed, 60 insertions(+), 26 deletions(-)

diff --git a/app/api/inventory.py b/app/api/inventory.py
index 2a75c36..91d0b1f 100644
--- a/app/api/inventory.py
+++ b/app/api/inventory.py
@@ -35,8 +35,7 @@ def get_item_by_id_or_ics_id(id_):
 @bp.route('/items')
 @jwt_required
 def get_items():
-    # TODO: add pagination
-    return get_generic_model(models.Item, request.args,
+    return get_generic_model(models.Item,
                              order_by=models.Item.created_at)
 
 
@@ -133,13 +132,13 @@ def create_item_comment(id_):
 @bp.route('/actions')
 @jwt_required
 def get_actions():
-    return get_generic_model(models.Action, request.args)
+    return get_generic_model(models.Action)
 
 
 @bp.route('/manufacturers')
 @jwt_required
 def get_manufacturers():
-    return get_generic_model(models.Manufacturer, request.args)
+    return get_generic_model(models.Manufacturer)
 
 
 @bp.route('/manufacturers', methods=['POST'])
@@ -152,7 +151,7 @@ def create_manufacturer():
 @bp.route('/models')
 @jwt_required
 def get_models():
-    return get_generic_model(models.Model, request.args)
+    return get_generic_model(models.Model)
 
 
 @bp.route('/models', methods=['POST'])
@@ -165,7 +164,7 @@ def create_model():
 @bp.route('/locations')
 @jwt_required
 def get_locations():
-    return get_generic_model(models.Location, request.args)
+    return get_generic_model(models.Location)
 
 
 @bp.route('/locations', methods=['POST'])
@@ -178,7 +177,7 @@ def create_locations():
 @bp.route('/status')
 @jwt_required
 def get_status():
-    return get_generic_model(models.Status, request.args)
+    return get_generic_model(models.Status)
 
 
 @bp.route('/status', methods=['POST'])
diff --git a/app/api/network.py b/app/api/network.py
index ef2c28c..b48e05d 100644
--- a/app/api/network.py
+++ b/app/api/network.py
@@ -9,7 +9,7 @@ This module implements the network API.
 :license: BSD 2-Clause, see LICENSE for more details.
 
 """
-from flask import Blueprint, request
+from flask import Blueprint
 from flask_jwt_extended import jwt_required
 from .. import models
 from ..decorators import jwt_groups_accepted
@@ -21,8 +21,7 @@ bp = Blueprint('network_api', __name__)
 @bp.route('/scopes')
 @jwt_required
 def get_scopes():
-    # TODO: add pagination
-    return get_generic_model(models.NetworkScope, request.args,
+    return get_generic_model(models.NetworkScope,
                              order_by=models.NetworkScope.name)
 
 
@@ -38,8 +37,7 @@ def create_scope():
 @bp.route('/networks')
 @jwt_required
 def get_networks():
-    # TODO: add pagination
-    return get_generic_model(models.Network, request.args,
+    return get_generic_model(models.Network,
                              order_by=models.Network.address)
 
 
@@ -55,8 +53,7 @@ def create_network():
 @bp.route('/interfaces')
 @jwt_required
 def get_interfaces():
-    # TODO: add pagination
-    return get_generic_model(models.Interface, request.args,
+    return get_generic_model(models.Interface,
                              order_by=models.Interface.ip)
 
 
@@ -71,7 +68,7 @@ def create_interface():
 @bp.route('/macs')
 @jwt_required
 def get_macs():
-    return get_generic_model(models.Mac, request.args,
+    return get_generic_model(models.Mac,
                              order_by=models.Mac.address)
 
 
diff --git a/app/api/user.py b/app/api/user.py
index 8868d30..ba22298 100644
--- a/app/api/user.py
+++ b/app/api/user.py
@@ -23,7 +23,7 @@ bp = Blueprint('user_api', __name__)
 @bp.route('/users')
 @jwt_required
 def get_users():
-    return get_generic_model(models.User, request.args,
+    return get_generic_model(models.User,
                              order_by=models.User.username)
 
 
diff --git a/app/api/utils.py b/app/api/utils.py
index 64471fd..168b266 100644
--- a/app/api/utils.py
+++ b/app/api/utils.py
@@ -9,6 +9,7 @@ This module implements useful functions for the API.
 :license: BSD 2-Clause, see LICENSE for more details.
 
 """
+import urllib.parse
 import sqlalchemy as sa
 from flask import current_app, jsonify, request
 from ..extensions import db
@@ -23,20 +24,58 @@ def commit():
         raise utils.CSEntryError(str(e), status_code=422)
 
 
-def get_generic_model(model, args, order_by=None):
+def build_pagination_header(pagination, base_url, **kwargs):
+    """Return the X-Total-Count and Link header information
+
+    :param pagination: flask_sqlalchemy Pagination class instance
+    :param base_url: request base_url
+    :param kwargs: extra query string parameters (without page and per_page)
+    :returns: dict with X-Total-Count and Link keys
+    """
+    header = {'X-Total-Count': pagination.total}
+    links = []
+    if pagination.page > 1:
+        params = urllib.parse.urlencode({'per_page': pagination.per_page,
+                                         'page': 1,
+                                         **kwargs})
+        links.append(f'<{base_url}?{params}>; rel="first"')
+    if pagination.has_prev:
+        params = urllib.parse.urlencode({'per_page': pagination.per_page,
+                                         'page': pagination.prev_num,
+                                         **kwargs})
+        links.append(f'<{base_url}?{params}>; rel="prev"')
+    if pagination.has_next:
+        params = urllib.parse.urlencode({'per_page': pagination.per_page,
+                                         'page': pagination.next_num,
+                                         **kwargs})
+        links.append(f'<{base_url}?{params}>; rel="next"')
+    if pagination.pages > pagination.page:
+        params = urllib.parse.urlencode({'per_page': pagination.per_page,
+                                         'page': pagination.pages,
+                                         **kwargs})
+        links.append(f'<{base_url}?{params}>; rel="last"')
+    if links:
+        header['Link'] = ', '.join(links)
+    return header
+
+
+def get_generic_model(model, order_by=None):
     """Return data from model as json
 
     :param model: model class
-    :param MultiDict args: args from the request
     :param order_by: column to order the result by
     :returns: data from model as json
     """
-    query = utils.get_query(model.query, request.args)
+    kwargs = request.args.to_dict()
+    page = int(kwargs.pop('page', 1))
+    per_page = int(kwargs.pop('per_page', 20))
+    query = utils.get_query(model.query, **kwargs)
     if order_by is None:
         order_by = getattr(model, 'name')
-    instances = query.order_by(order_by)
-    data = [instance.to_dict() for instance in instances]
-    return jsonify(data)
+    pagination = query.order_by(order_by).paginate(page, per_page)
+    data = [item.to_dict() for item in pagination.items]
+    header = build_pagination_header(pagination, request.base_url, **kwargs)
+    return jsonify(data), 200, header
 
 
 def create_generic_model(model, mandatory_fields=('name',), **kwargs):
diff --git a/app/utils.py b/app/utils.py
index dd9fbec..ee68efe 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -135,15 +135,14 @@ def get_model_choices(model, allow_none=False, attr='name', query=None):
     return choices
 
 
-def get_query(query, args):
+def get_query(query, **kwargs):
     """Retrieve the query from the arguments
 
     :param query: sqlalchemy base query
-    :param MultiDict args: args from a request
+    :param kwargs: kwargs from a request
     :returns: query filtered by the arguments
     """
-    if args:
-        kwargs = args.to_dict()
+    if kwargs:
         try:
             query = query.filter_by(**kwargs)
         except (sa.exc.InvalidRequestError, AttributeError) as e:
-- 
GitLab