This task is to build a safe RESTful API that extends the Flask PWA - Programming for the Web Task. From the parent task, students will abstract the database and management to an REST API with key authentication. The PWA will then be retooled to GET request the data from the REST API and POST request data to the REST API. The PWA UI for the API will be rapidly prototyped using the Bootstrap frontend framework.
The API instructions focus on modelling how to build and test an API incrementally. The PWA instructions focus on using the Bootstrap frontend framework to prototype an enhanced UI/UX frontend rapidly using Bootstrap components and classes.
Note
The template for this project has been pre-populated with assets from the Flask PWA task, including the logo, icons and database. Students can migrate their own assets if they wish.
- VSCode or GitHub Codespaces
- Python 3.x
- GIT 2.x.x +
- SQLite3 Editor
- Start git-bash 6.Thunder Client
- pip/pip3 installs
pip install Flask
pip install SQLite3
pip install flask_wtf
pip install flask_csp
pip install jsonschema
pip install requests
Important
MacOS and Linux users may have a pip3
soft link instead of pip
, run the below commands to see what path your system is configured with and use that command through the project. If neither command returns a version, then likely Python 3.x needs to be installed.
pip show pip
pip3 show pip
Warning
These instructions are less verbose than the parent task because students are expected to be now familiar with Bash, Flask & SQLite3.
Watch: Build a Flask API in 12 Minutes
Note
The video uses Postman, this tutorial uses Thunder Client a VS Code extension that has similar functionality.
Students can create files as they are needed. This structure defines the correct directory structure for all files. As students touch
each file, they should refer to this structure to ensure the file path is correct.
├── database
│ └─── data_source.db
├── static
│ ├── css
│ │ ├──bootstrap.min.css
│ │ └──style.css
│ ├── icons
│ │ ├──desktop_screenshot.png
│ │ ├──icon-128x128.png
│ │ ├──icon-192x192.png
│ │ ├──icon-384x384.png
│ │ ├──icon-512x512.png
│ │ └──mobile_screenshot.png
│ ├── images
│ │ ├──favicon.png
│ │ └──logo.png
│ ├─── js
│ │ ├──app.js
│ │ ├──bootstrap.bundle.min.js
│ │ └──serviceWorker.js
│ └── manifest.json
├── templates
│ ├── partials
│ │ ├──footer.html
│ │ └──menu.html
│ ├──index.html
│ ├──layout.html
│ └──privacy.html
├── api.py
├── database_manager.py
├── LICENSE
└── main.py
This Python implementation in 'api.py':
- Imports all the required dependencies for the whole project.
- Configure the 'Cross Origin Request' policy.
- Configure the rate limiter.
- Configure a route for the root
/
with a GET method to return stub data and a 200 response. - Configure a route to /add_extension with a POST method to return stub data and a 201 response.
from flask import Flask
from flask import request
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import logging
import database_manager as dbHandler
api = Flask(__name__)
cors = CORS(api)
api.config["CORS_HEADERS"] = "Content-Type"
limiter = Limiter(
get_remote_address,
app=api,
default_limits=["200 per day", "50 per hour"],
storage_uri="memory://",
)
@api.route("/", methods=["GET"])
@limiter.limit("3/second", override_defaults=False)
def get():
return ("API Works"), 200
@api.route("/add_extension", methods=["POST"])
@limiter.limit("1/second", override_defaults=False)
def post():
data = request.get_json()
return data, 201
if __name__ == "__main__":
api.run(debug=True, host="0.0.0.0", port=3000)
Extend the get():
method in api.py
to get data from the database via the dbHandler
and return it to the request with a status 200
.
def get():
content = dbHandler.extension_get("%")
return (content), 200
This Python implementation in 'database_manager.py'
- Imports all the required dependencies for the project
- Connects to the SQLite3 database
- Executes a query
- Converts the query data to a JSON structure
- Returns the JSON data
from flask import jsonify
import sqlite3 as sql
from jsonschema import validate
from flask import current_app
def extension_get(lang):
con = sql.connect("database/data_source.db")
cur = con.cursor()
cur.execute("SELECT * FROM extension")
migrate_data = [
dict(
extID=row[0],
name=row[1],
hyperlink=row[2],
about=row[3],
image=row[4],
language=row[5],
)
for row in cur.fetchall()
]
return jsonify(migrate_data)
Extend the get():
method in api.py
to either get all data or data that matches a language parameter from the database by
- Validating the argument is "lang" and that the "lang" is only alpha characters for security.
- Passing the language request to the dbHandler.
- If no language is specified, the wildcard
%
will be passed. - Return the data from dbHandler to the request.
- Return a status
200
.
def get():
# For security data is validated on entry
if request.args.get("lang") and request.args.get("lang").isalpha():
lang = request.args.get("lang")
lang = lang.upper()*
content = dbHandler.extension_get(lang)
else:
content = dbHandler.extension_get("%")
return (content), 200
Extend the database query in the extension_get():
method in the database_manager.py
to filter the SQL query based on the argument parameter and return it as JSON data where:
- If no valid parameter is passed, the function will return the entire database in a JSON format because of the
%
wildcard. - If a valid parameter is passed, the database will be queried with a `WHERE language LIKE' SQL query, and all matching languages (if any) will be returned in JSON format.
def extension_get(lang):
con = sql.connect("database/data_source.db")
cur = con.cursor()
cur.execute("SELECT * FROM extension WHERE language LIKE ?;", [lang])
migrate_data = [
dict(
extID=row[0],
name=row[1],
hyperlink=row[2],
about=row[3],
image=row[4],
language=row[5],
)
for row in cur.fetchall()
]
return jsonify(migrate_data)
Extend the /add_extension
route in api.py to pass the POST data to the 'dbHandler' and set up a driver to return the response with a 201 status code.
def post():
data = request.get_json()
response = dbHandler.extension_add(data)
return response
Extend the extension_add():
method in the database_manager.py
to be a driver that returns the received data to the POST request.
def extension_add(response):
data = response
return data, 200
Update the extension_add():
method in database_manager.py
to validate the JSON and return a message and response code. The schema provided validates the JSON with the following rules:
- All 5 properties are required.
- No extra properties are allowed.
- The data type for all 5 properties is string.
- The hyperlink pattern enforces the URL to start with
https://marketplace.visualstudio.com/items?itemName=
, and the characters<
and>
are not allowed to prevent XXS attacks. - The image pattern requires https:// but
<
and>
are not allowed to prevent XXS attacks. - Languages must be enumerated with the list of languages.
Important
You can use https://regex101.com/ to design and test patterns for your database design. Regular expressions in Python require a raw string (with the r prefix) due to the way characters need to be escaped.
if validate_json(data):
return {"message": "Extension added successfully"}, 201
else:
return {"error": "Invalid JSON"}, 400
schema = {
"type": "object",
"validationLevel": "strict",
"required": [
"name",
"hyperlink",
"about",
"image",
"language",
],
"properties": {
"name": {"type": "string"},
"pattern": r"^https:\/\/marketplace\.visualstudio\.com\/items\?itemName=(?!.*[<>])[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=]*$",
},
"about": {"type": "string"},
"image": {
"type": "string",
"pattern": r"^https:\/\/(?!.*[<>])[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=]*$",
},
"language": {
"type": "string",
"enum": ["PYTHON", "CPP", "BASH", "SQL", "HTML", "CSS", "JAVASCRIPT"],
},
},
"additionalProperties": False,
}
def validate_json(json_data):
try:
validate(instance=json_data, schema=schema)
return True
except:
return False
Sample JSON data for you to test the API:
{"name": "test", "hyperlink": "https://marketplace.visualstudio.com/items?itemName=123.html", "about": "This is a test", "image": "https://test.jpg", "language": "BASH"}
Update the extension_add():
method in database_manager.pyto INSERT the JSON data into the database. The
extID` is not required as it has been configured to auto increment in the database table.
def extension_add(data):
if validate_json(data):
con = sql.connect("database/data_source.db")
cur = con.cursor()
cur.execute(
"INSERT INTO extension (name, hyperlink, about, image, language) VALUES (?, ?, ?, ?, ?);",
[
data["name"],
data["hyperlink"],
data["about"],
data["image"],
data["language"],
],
)
con.commit()
con.close()
return {"message": "Extension added successfully"}, 201
else:
return {"error": "Invalid JSON"}, 400
API Key Authorisation is a common method for authorising an application, site, or project. In this scenario, the API is not authorising a specific user. This is a very simple implementation of API Key Authorisation.
Extend the api.py
to store the key as a variable. Students will need to generate a unique basic 16 secret key with https://acte.ltd/utils/randomkeygen.
auth_key = "4L50v92nOgcDCYUM"
Extend the def post():
method in api.py
to request the authorisation
attribute from the post head, compare it to the auth_key,
and process the appropriate response.
def post():
if request.headers.get("Authorisation") == auth_key:
data = request.get_json()
response = dbHandler.extension_add(data)
return response
else:
return {"error": "Unauthorised"}, 401
Extend the api.py
with the implementation below, which should be inserted directly below the imports
. This will configure the logger to log to a file for security analysis.
api_log = logging.getLogger(__name__)
logging.basicConfig(
filename="api_security_log.log",
encoding="utf-8",
level=logging.DEBUG,
format="%(asctime)s %(message)s",
)
Note
This implementation uses the Bootstrap frontend CSS & JS design framework. Version 5.3.3 has been included in the static files.
├── templates
│ ├── partials
│ │ ├──footer.html
│ │ └──menu.html
│ ├──index.html
│ ├──layout.html
│ └──privacy.html
This Jinga2/HTML implementation in layout.html:
- Security features are defined in the head.
- The menu and footer are defined in a partial for easy maintenance.
- The body will be defined by the block content when the
layout.html
is inherited. - Bootstrap components (CSS & JavaScript) are linked.
- JS Components, including the PWA service worker, are linked.
<!DOCTYPE html>
<html lang="en">
<head>
<meta
http-equiv="Content-Security-Policy"
content="base-uri 'self'; default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' *; media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'self'; worker-src 'self'; frame-src 'none'; form-action 'self'; manifest-src 'self'"
/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" type="text/css" href="static/css/style.css" />
<title>VS Code Extensions for Software Engineering</title>
<link rel="manifest" href="static/manifest.json" />
<link rel="icon" type="image/x-icon" href="static/images/favicon.png" />
<meta name="theme-color" content="" />
<link href="static/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
{% include "partials/menu.html" %}
<main>{% block content %}{% endblock %}</main>
{% include "partials/footer.html" %}
<script src="static/js/bootstrap.bundle.min.js"></script>
<script src="static/js/serviceWorker.js"></script>
<script src="static/js/app.js"></script>
</body>
</html>
This HTML implementation provides a full-width horizontal rule and a Bootstrap column containing a link to the privacy page.
<div class="container-fluid">
<hr />
</div>
<div class="container">
<div class="row">
<div class="col-12">
<a href="privacy.html">Privacy Policy</a>
</div>
</div>
</div>
This HTML implementation is an adaption of the basic Bootstrap Navbar.
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<img src="static/images/logo.png" alt="logo" height="80" />
</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" href="/" aria-current="page">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/add.html">Add Extension</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/privacy.html">Privacy</a>
</li>
</ul>
<form class="d-flex" role="search" id="search-form">
<input
class="form-control me-2"
type="search"
placeholder="Search"
aria-label="Search"
id="search-input"
/>
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>
Extend the app.js
with this script that toggles the active class and the aria-current="page"
attribute for the current page menu item. The active
class improves UX by styling the current page in the menu differently and adding the aria-current
attribute to the current page which improves the context understanding of screen readers for enhanced accessibility.
document.addEventListener("DOMContentLoaded", function () {
const navLinks = document.querySelectorAll(".nav-link");
const currentUrl = window.location.pathname;
navLinks.forEach((link) => {
const linkUrl = link.getAttribute("href");
if (linkUrl === currentUrl) {
link.classList.add("active");
link.setAttribute("aria-current", "page");
} else {
link.classList.remove("active");
link.removeAttribute("aria-current");
}
});
});
Extend the app.js
with this script that adds basic search functionality to the search button in the menu by searching the current page and highlighting matching words.
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("search-form");
const input = document.getElementById("search-input");
form.addEventListener("submit", function (event) {
event.preventDefault();
const searchTerm = input.value.trim().toLowerCase();
if (searchTerm) {
highlightText(searchTerm);
}
});
function highlightText(searchTerm) {
const mainContent = document.querySelector("main");
removeHighlights(mainContent);
highlightTextNodes(mainContent, searchTerm);
}
function removeHighlights(element) {
const highlightedElements = element.querySelectorAll("span.highlight");
highlightedElements.forEach((el) => {
el.replaceWith(el.textContent);
});
}
function highlightTextNodes(element, searchTerm) {
const regex = new RegExp(`(${searchTerm})`, "gi");
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while ((node = walker.nextNode())) {
const parent = node.parentNode;
if (
parent &&
parent.nodeName !== "SCRIPT" &&
parent.nodeName !== "STYLE"
) {
const text = node.nodeValue;
const highlightedText = text.replace(
regex,
'<span class="highlight">$1</span>'
);
if (highlightedText !== text) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = highlightedText;
while (tempDiv.firstChild) {
parent.insertBefore(tempDiv.firstChild, node);
}
parent.removeChild(node);
}
}
}
}
});
Extend style.css to add the class required by the search script.
.highlight {
background-color: yellow;
border-radius: 20px;
border: 1px yellow solid;
}
Insert the basic HTML into index.html.
{% extends 'layout.html' %} {% block content %}
<div class="container">
<div class="row"></div>
<div class="row"></div>
</div>
{% endblock %}
This Python Flask implementation in main.py
- Imports all dependencies required for the whole project.
- Set up CSRFProtect to provide asynchronous keys that protect the app from a CSRF attack. Students will need to generate a unique basic 16 secret key with https://acte.ltd/utils/randomkeygen.
- Defines the head attribute for authorising a POST request to the API.
- Define a secure Content Secure Policy (CSP) head.
- Configures the Flask app.
- Redirect /index.html to the domain root for a consistent user experience.
- Renders the index.html for a GET app route.
- Provide an endpoint to log CSP violations for security analysis.
from flask import Flask
from flask import redirect
from flask import render_template
from flask import request
import requests
from flask_wtf import CSRFProtect
from flask_csp.csp import csp_header
import logging
# Generate a unique basic 16 key: https://acte.ltd/utils/randomkeygen
app = Flask(__name__)
csrf = CSRFProtect(app)
app.secret_key = b"6HlQfWhu03PttohW;apl"
app_header = {"Authorisation": "4L50v92nOgcDCYUM"}
@app.route("/index.html", methods=["GET"])
def root():
return redirect("/", 302)
@app.route("/", methods=["GET"])
@csp_header(
{
"base-uri": "self",
"default-src": "'self'",
"style-src": "'self'",
"script-src": "'self'",
"img-src": "*",
"media-src": "'self'",
"font-src": "self",
"object-src": "'self'",
"child-src": "'self'",
"connect-src": "'self'",
"worker-src": "'self'",
"report-uri": "/csp_report",
"frame-ancestors": "'none'",
"form-action": "'self'",
"frame-src": "'none'",
}
)
def index():
return render_template("/index.html")
@app.route("/csp_report", methods=["POST"])
@csrf.exempt
def csp_report():
app.logger.critical(request.data.decode())
return "done"
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)
python main.py
{% extends 'layout.html' %} {% block content %}
<div class="container">
<div class="row">
<h1 class="display-1">Privacy Policy</h1>
<p>Policy here...</p>
</div>
<div class="row"></div>
</div>
{% endblock %}
Extend main.py
to include an app route to privacy.html
@app.route("/privacy.html", methods=["GET"])
def privacy():
return render_template("/privacy.html")
Ensure your page renders correctly with the test cases:
- The page renders correctly
- The privacy menu item is darker than the other menu items
- A search for "priv" highlights the correct letters in the main body.
Extend the index():
method in main.py
so it requests data from the API and handles the exception that the API did not respond with an error message.
def index():
url = "http://127.0.0.1:3000"
try:
response = requests.get(url)
response.raise_for_status() # Raise an exception for HTTP errors
data = response.json()
except requests.exceptions.RequestException as e:
data = {"error": "Failed to retrieve data from the API"}
return render_template("index.html", data=data)
Replace the test html in 'index.html` template that:
- Implements a Bootstrap jumbotron heading.
- Implements a Bootstrap button group that will later allow users to filter the extensions by language.
- Implements the database items as Bootstrap cards in a responsiveBootstrap Column Layout.
- Provides API error feedback to the user that is styled by the Bootstrap color utility.
- Apply Bootstrap sizing and Bootstrap spacing utilities to layout the cards.
{% extends 'layout.html' %} {% block content %}
<div class="container py-4"">
<div class="p-4 bg-body-tertiary rounded-3">
<div class="container-fluid py-2">
<h1 class="display-4">VS Code Extensions for Software Engineering</h1>
<p class="lead">
This is a collection of Visual Studio Code extensions that are useful
for software engineering.
</p>
</div>
</div>
</div>
<div class="container">
<div class="row">
<div class="btn-group" role="group" aria-label="Filter by language">
<button type="button" class="btn btn-primary" id="all">All</button>
<button type="button" class="btn btn-primary" id="python">Python</button>
<button type="button" class="btn btn-primary" id="c++">C++</button>
<button type="button" class="btn btn-primary" id="bash">BASH</button>
<button type="button" class="btn btn-primary" id="sql">SQL</button>
<button type="button" class="btn btn-primary" id="html">HTML</button>
<button type="button" class="btn btn-primary" id="css">CSS</button>
<button type="button" class="btn btn-primary" id="js">JAVASCRIPT</button>
</div>
</div>
</div>
<div class="container pt-4">
<div class="row">
<div class="error"><h2 class="text-danger">{{ data.error }}</h2></div>
{% if data.error is not defined %}
{% for row in data %}
<div class="col-sm-12 col-lg-4 mb-4">
<div class="card h-100" style="width: 18rem">
<img
src="{{ row.image }}"
class="card-img-top"
alt="Product image for the {{ row.name }} VSCode extension."
/>
<div class="card-body">
<h5 class="card-title">{{ row.name }}</h5>
<p class="card-text">{{ row.about }}</p>
<a href="{{ row.hyperlink }}" class="btn btn-primary">Read More</a>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endblock %}
The HTML Implementation in add.html
- Provides error and message feedback to the user that is styled by the Bootstrap color utility.
- Uses Bootstrap Forms to layout a data entry form.
- Uses Form attributes type, place holder & pattern to improve user experience in entering the correct data.
{% extends 'layout.html' %} {% block content %}
<div class="container">
<div class="row">
<h1>Add an Extension</h1>
<div class="error">
<h2>
<span class="text-danger">{{ data.error }}</span
><span class="text-success">{{ data.message }}</span>
</h2>
</div>
</div>
</div>
<div class="container">
<div class="row">
<form action="/add.html" method="POST" class="box">
<div class="col-auto">
<label for="name" class="form-label">Extension name</label>
<textarea
id="name"
name="name"
class="form-control"
rows="1"
autocomplete="off"
></textarea>
</div>
<div class="col-auto">
<label for="hyperlink" name="hyperlink" class="form-label"
>Hyperlink to extension</label
>
<input
id="hyperlink"
name="hyperlink"
type="url"
class="form-control"
placeholder="https://marketplace.visualstudio.com/items?itemName="
pattern="^https:\/\/marketplace\.visualstudio\.com\/items\?itemName=(?!.*[<>])[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=]*$"
/>
</div>
<div class="col-auto">
<label for="about" class="form-label">About</label>
<textarea
id="about"
name="about"
class="form-control"
rows="3"
placeholder="A brief description of the extension"
></textarea>
</div>
<div class="col-auto">
<label for="name" name="image" class="form-label">URL to Icon</label>
<input
id="image"
name="image"
type="url"
class="form-control"
pattern="^https:\/\/(?!.*[<>])[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=]*$"
placeholder="https://"
/>
</div>
<div class="col-auto">
<label for="language" name="language" class="form-label"
>Programming language</label
>
<select
id="language"
name="language"
class="form-select"
aria-label="Default select language"
>
<option selected>Select a language from this menu</option>
<option value="PYTHON">PYTHON</option>
<option value="CPP">CPP</option>
<option value="BASH">BASH</option>
<option value="SQL">SQL</option>
<option value="HTML">HTML</option>
<option value="CSS">CSS</option>
<option value="JAVASCRIPT">JAVASCRIPT</option>
</select>
</div>
<br />
<div class="col-auto">
<button type="submit" class="btn btn-primary mb-3">Submit</button>
</div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
</form>
</div>
</div>
{% endblock %}
Extend main.py' to provide a route with POST and GET methods for
add.html` that
- Renders
add.html
on GET requests. - On a POST method, read the form in add.html and construct a JSON.
- Then, POST the JSON with the header that includes the Authentication key to the API.
- Render
add.html
with any errors or messages from the API.
@app.route("/add.html", methods=["POST", "GET"])
def form():
if request.method == "POST":
name = request.form["name"]
hyperlink = request.form["hyperlink"]
about = request.form["about"]
image = request.form["image"]
language = request.form["language"]
data = {
"name": name,
"hyperlink": hyperlink,
"about": about,
"image": image,
"language": language,
}
app.logger.critical(data)
try:
response = requests.post(
"http://127.0.0.1:3000/add_extension",
json=data,
headers=app_header,
)
data = response.json()
except requests.exceptions.RequestException as e:
data = {"error": "Failed to retrieve data from the API"}
return render_template("/add.html", data=data)
else:
return render_template("/add.html", data={})
Extend app.js
with a script to provide functionality to the home page buttons.
document.addEventListener("DOMContentLoaded", function () {
if (window.location.pathname === "/") {
const buttons = [
{ id: "all", url: "/" },
{ id: "python", url: "?lang=python" },
{ id: "cpp", url: "?lang=cpp" },
{ id: "bash", url: "?lang=bash" },
{ id: "sql", url: "?lang=sql" },
{ id: "html", url: "?lang=html" },
{ id: "css", url: "?lang=css" },
{ id: "js", url: "?lang=javascript" },
];
buttons.forEach((button) => {
const element = document.getElementById(button.id);
if (element) {
element.addEventListener("click", function () {
window.location.href = button.url;
});
}
});
}
});
url = "http://127.0.0.1:3000"
if request.args.get("lang") and request.args.get("lang").isalpha():
lang = request.args.get("lang")
url += f"?lang={lang}"
Extend the main.py
with the implementation below, which should be inserted directly below the imports
. This will configure the logger to log to a file for security analysis.
app_log = logging.getLogger(__name__)
logging.basicConfig(
filename="main_security_log.log",
encoding="utf-8",
level=logging.DEBUG,
format="%(asctime)s %(message)s",
)
- Create a get_languages method that returns all the languages in the database.
- Improve exception handling of the
add_extension
endpoint to give more detailed feedback to the user. - Use the new get-languages method to define the content that renders in the PWA.
- Implement a sort extension by function
Flask PWA API Extension Task Source and Flask PWA API Extension Task Template by Ben Jones is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International