diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..398fbff --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.vscode +*.pyc +__pycache__ +api.log +api.db +venv diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a53ea1d --- /dev/null +++ b/.pylintrc @@ -0,0 +1,4 @@ +[MASTER] +disable=invalid-name +ignored-modules=flask_sqlalchemy +disable=E1101 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb7a164 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +## Overview + +Python Flask-based API server template for creating a secure REST API. This +server has everything you need properly configured out of the box to create your +own API. You can use it to call local scripts, relay to other APIs, or do +anything you can do in Python. Clients can be anything that supports REST calls +(curl, bash, powershell etc). Currently apisrv only supports local authentation, +but it could be extended with LDAP support and auth tokens. Please contact me at +([yantisj](https://github.com/yantisj)) for any help. + +## Requirements +* Python 3.4+ +* VirtualEnv +* SSL Certificates (can test with non-HTTPS but should not be used in production) + +## Features +* Built-in SSL server +* Example functions for creating your own secure API +* Sample Rest Client +* Strong Password Hashing (stored in sqlite DB) +* Rate limiting for safety +* JSONified error handling +* Extensive logging to both stdout and log files + +## Installing +* Make sure you have virtualenv and Python3 installed on your system (pip3 install virtualenv) +* Clone the repo: ```git clone https://git.musc.edu/yantisj/apisrv.git``` +* Install the virtualenv: ```cd apisrv; virtualenv venv``` +* Activate the virtual environment: ```source venv/bin/activate``` +* Install Python Requirements: ```pip3 install -r requirements.txt``` + +## Testing +* You must activate the virtualenv before starting the server +* Initialize DB and Add a user: ```./ctlsrv.py --init; ./ctlsrv.py --add testuser``` +* Start the server: ```./runsrv.py``` +* Test the info request with a browser: [/test/api/v1.0/info?testvar=testing](http://localhost:5000/test/api/v1.0/info?testvar=testing) +* Test via CURL: ```curl -u testuser:testpass http://localhost:5000/test/api/v1.0/info?var1=test``` +* Test via the Sample Rest Client: ```./test/rest-client.py``` +* Get the system uptime/load: [/test/api/v1.0/uptime](http://localhost:5000/test/api/v1.0/uptime) + +## Customizing For Use +* See [api.conf](api.conf) +* Enable https and add a certificate to enable listening beyond localhost +* Add custom methods to [apisrv/views.py](apisrv/views.py) +* Make sure to add the @auth.login_required decorator to each method for security +* Be careful when making system calls with user input +* Check the API rate limits in [apisrv/\_\_init\_\_.py](apisrv/__init__.py) + +## Using VirtualEnv with Visual Studio Code +* Make sure you have the latest version of the Python Extension installed +* Use Command+Shift+P: Python: Select Workspace Interpreter and choose python under venv/bin + +## Further Reading +* [Creating a REST API](https://realpython.com/blog/python/api-integration-in-python/) diff --git a/api.conf b/api.conf new file mode 100644 index 0000000..3d69596 --- /dev/null +++ b/api.conf @@ -0,0 +1,18 @@ +## Flask API Server Configuration File + +[apisrv] +app_name = test +port = 5000 + +# Debug mode logs to stdout and enable Flask debugging +# Set to 0 for production! +debug = 1 + +# HTTPS must not be set to 0 for production server +https = 0 +logfile = api.log +database = ../api.db + +# SSL Certificate and Key, required for enabling HTTPS +ssl_crt = ssl.crt +ssl_key = ssl.key diff --git a/apisrv/__init__.py b/apisrv/__init__.py new file mode 100644 index 0000000..a4eef2c --- /dev/null +++ b/apisrv/__init__.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +# +""" +Flask API Server +""" +import sys +import os +import re +import logging +from logging.handlers import RotatingFileHandler +import configparser +import sqlite3 +from flask import Flask, jsonify, request, g, make_response +from flask_httpauth import HTTPBasicAuth +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_sqlalchemy import SQLAlchemy + +logger = logging.getLogger(__name__) + +# Populated from config file +debug = 0 + +# Flask Limits for Safety +flask_limits = ["1000 per day", "100 per hour", "5 per minute"] + +# Initialize Configuration +config_file = 'api.conf' +config = configparser.ConfigParser() +config.read(config_file) + +# Check for sane config file +if 'apisrv' not in config: + print("Could not parse config file: " + config_file) + sys.exit(1) + +# Logging Configuration, default level INFO +logger = logging.getLogger('') +logger.setLevel(logging.INFO) +lformat = logging.Formatter('%(asctime)s %(name)s:%(levelname)s: %(message)s') + +# Debug mode +if 'debug' in config['apisrv'] and int(config['apisrv']['debug']) != 0: + debug = int(config['apisrv']['debug']) + logger.setLevel(logging.DEBUG) + logging.debug('Enabled Debug mode') + +# Enable logging to file if configured +if 'logfile' in config['apisrv']: + lfh = RotatingFileHandler(config['apisrv']['logfile'], maxBytes=(1048576*5), backupCount=3) + lfh.setFormatter(lformat) + logger.addHandler(lfh) + +# STDOUT Logging defaults to Warning +if not debug: + lsh = logging.StreamHandler(sys.stdout) + lsh.setFormatter(lformat) + lsh.setLevel(logging.WARNING) + logger.addHandler(lsh) + +# Create Flask APP +app = Flask(__name__) +app.config.from_object(__name__) +app.config.update(dict( + DATABASE=os.path.join(app.root_path, config['apisrv']['database']), + SQLALCHEMY_DATABASE_URI='sqlite:///' + os.path.join(app.root_path, config['apisrv']['database']), + SQLALCHEMY_TRACK_MODIFICATIONS=False, + #SECRET_KEY='development key', + #USERNAME='dbuser', + #PASSWORD='dbpass' +)) + +# Auth module +auth = HTTPBasicAuth() + +# Database module +db = SQLAlchemy(app) + +# Apply Rate limiting +limiter = Limiter( + app, + key_func=get_remote_address, + global_limits=flask_limits +) + +# Helper Functions +def get_db(): + """Opens a new database connection if there is none yet for the + current application context. + """ + if not hasattr(g, 'sqlite_db'): + g.sqlite_db = connect_db() + return g.sqlite_db + +def connect_db(): + """Connects to the specific database.""" + conn = sqlite3.connect(app.config['DATABASE']) + conn.row_factory = sqlite3.Row + return conn + +def initialize_db(): + """ Initialize the database structure""" + conn = connect_db() + c = conn.cursor() + c.execute('''CREATE TABLE user + (username text, password text, token text) ''') + conn.commit() + conn.close() + +@app.teardown_appcontext +def close_db(error): + """Closes the database again at the end of the request.""" + if hasattr(g, 'sqlite_db'): + g.sqlite_db.close() + +# Safe circular imports per Flask guide +import apisrv.errors +import apisrv.views +import apisrv.user + + diff --git a/apisrv/errors.py b/apisrv/errors.py new file mode 100644 index 0000000..53dc3e6 --- /dev/null +++ b/apisrv/errors.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +""" +API Errors Jsonified +""" +import logging +from flask import Flask, jsonify, request, g, make_response +from apisrv import app, auth + +logger = logging.getLogger(__name__) + +@app.errorhandler(404) +def not_found(error=None): + message = { + 'status': 404, + 'message': 'Not Found: ' + request.url, + } + resp = jsonify(message) + resp.status_code = 404 + return resp + +@app.errorhandler(429) +def ratelimit_handler(e): + return make_response( + jsonify(error="ratelimit exceeded %s" % e.description) + , 429 + ) + +@auth.error_handler +def auth_failed(error=None): + message = { + 'status': 401, + 'message': 'Authentication Failed: ' + request.url + } + resp = jsonify(message) + resp.status_code = 401 + + return resp + +@app.errorhandler(400) +def bad_request(error): + print("Bad Request") + message = { + 'status': 400, + 'message': 'Bad Request: ' + request.url, + } + resp = jsonify(message) + resp.status_code = 400 + return resp + + diff --git a/apisrv/user.py b/apisrv/user.py new file mode 100644 index 0000000..6123dfb --- /dev/null +++ b/apisrv/user.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +""" +API User Functions +""" +import sys +import logging +import time +from passlib.hash import sha256_crypt + +import sqlite3 +from flask_sqlalchemy import SQLAlchemy +from apisrv import auth, config, get_db, connect_db, db + +logger = logging.getLogger(__name__) + +class User(db.Model): + """ SQL User Model""" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True) + password = db.Column(db.String(120), unique=True) + role = db.Column(db.String(40)) + + def __init__(self, username, password, role='default'): + self.username = username + self.password = password + self.role = role + + def __repr__(self): + return ''.format(self.username) + +@auth.verify_password +def verify_password(username, password): + """API Password Verification""" + + if authenticate_user(username, password): + return True + return False + +def authenticate_user(username, passwd): + """ Authenticate a user """ + + user = User.query.filter_by(username=username).first() + + authenticated = False + + if user: + authenticated = sha256_crypt.verify(passwd, user.password) + else: + time.sleep(1) + logger.info("Authentication Error: User not found in DB: %s", username) + return False + + if authenticated: + logger.debug("Successfully Authenticated user: %s", username) + else: + logger.info("Authentication Failed: %s", username) + + return authenticated + + +def add_user(username, passwd): + """ + Add a new user to the database + """ + + user = User.query.filter_by(username=username).first() + + if user: + #print("Error: User already exists in DB", file=sys.stderr) + raise Exception("Error: User already exists in DB") + elif len(passwd) < 6: + print("Error: Password must be 6 or more characters", file=sys.stderr) + exit(1) + else: + logger.info("Adding new user to the database: %s", username) + + phash = sha256_crypt.encrypt(passwd) + + newuser = User(username, phash) + db.session.add(newuser) + db.session.commit() + + return phash + +def update_password(username, passwd): + """ Update password for user """ + + user = User.query.filter_by(username=username).first() + + if len(passwd) < 6: + print("Error: Password must be 6 or more characters", file=sys.stderr) + exit(1) + elif user: + logger.info("Updating password for user: %s", username) + + phash = sha256_crypt.encrypt(passwd) + + user.password = phash + db.session.commit() + + return phash + else: + + print("Error: User does not exists in DB", file=sys.stderr) + exit(1) + +def del_user(username): + """ Delete a user from the database """ + + user = User.query.filter_by(username=username).first() + + if user: + logger.info("Deleting user: %s", username) + + db.session.delete(user) + db.session.commit() + + return True + else: + + print("Error: User does not exists in DB", file=sys.stderr) + exit(1) + diff --git a/apisrv/views.py b/apisrv/views.py new file mode 100644 index 0000000..35d892e --- /dev/null +++ b/apisrv/views.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +""" +API Views for serving user requests with examples +Add your own methods here +""" +import logging +import subprocess +from flask import jsonify, request +from apisrv import app, auth, config + +# Setup +logger = logging.getLogger(__name__) +app_name = config['apisrv']['app_name'] + +# http://localhost:5000/test unauthenticated response of "Hello World" +@app.route('/test') +def app_test(): + """ Test Method, no authentication required """ + logger.info("Hello World Requested") + response = {"message": "Hello World!", "status": "200"} + return jsonify(response) + +# System Uptime, requires authentication +@app.route('/' + app_name + '/api/v1.0/uptime') +@auth.login_required +def app_uptime(): + """ Runs a system command to get the uptime""" + p = subprocess.Popen('uptime', stdout=subprocess.PIPE, universal_newlines=True) + uptime = p.stdout.readlines()[0].strip() + response = {"message": uptime, "status": "200"} + return jsonify(response) + +# Call echo shell cmd on message via /musc/api/v1.0/echo?message=testing +@app.route('/' + app_name + '/api/v1.0/echo', methods=['GET']) +@auth.login_required +def app_echo(): + """ Runs the echo command with input 'message' + Note: Input is validated and escaped properly + """ + message = request.args.get('message', '') + if message: + p = subprocess.Popen(['echo', message], stdout=subprocess.PIPE, universal_newlines=True) + uptime = p.stdout.readlines()[0].strip() + response = {"message": uptime, "status": "200"} + return jsonify(response) + else: + return jsonify({"error": "Must provide message attribute via GET"}) + +# Info method, Return Request Data back to client as JSON +@app.route('/' + app_name + '/api/v1.0/info', methods=['POST', 'GET']) +@auth.login_required +def app_getinfo(): + """ Returns Flask API Info """ + response = dict() + response['message'] = "Flask API Data" + response['status'] = "200" + response['method'] = request.method + response['path'] = request.path + response['remote_addr'] = request.remote_addr + response['user_agent'] = request.headers.get('User-Agent') + + # GET attributes + for key in request.args: + response['GET ' + key] = request.args.get(key, '') + # POST Attributes + for key in request.form.keys(): + response['POST ' + key] = request.form[key] + + return jsonify(response) + +@app.route('/') +def app_index(): + """Index identifying the server""" + response = {"message": app_name + \ + " server: Authentication required for use", + "status": "200"} + return jsonify(response) diff --git a/ctlsrv.py b/ctlsrv.py new file mode 100755 index 0000000..d7858f5 --- /dev/null +++ b/ctlsrv.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +""" +Manage the API Server +""" +import sys +import getpass +import apisrv +import argparse + +# Config File +config = apisrv.config + +parser = argparse.ArgumentParser() + +parser = argparse.ArgumentParser(prog='ctlsrv.py', + description='Manage the API Server') + +parser.add_argument("--adduser", metavar="user", help="Add API User to DB", type=str) +parser.add_argument("--newpass", metavar="user", help="Update Password", type=str) +parser.add_argument("--testuser", metavar="user", help="Test Authentication", type=str) +parser.add_argument("--deluser", metavar="user", help="Delete API User", type=str) +parser.add_argument("--initdb", help="Initialize the Database", action="store_true") +parser.add_argument("--debug", help="Set debugging level", type=int) +parser.add_argument("-v", help="Verbose Output", action="store_true") + +args = parser.parse_args() + +verbose = 0 +if args.v: + verbose = 1 +if args.debug: + verbose = args.debug + +# Initialize the Database +if args.initdb: + apisrv.db.create_all() + apisrv.db.session.commit() + +# Add user to DB +elif args.adduser: + + passwd = getpass.getpass('Password:') + verify = getpass.getpass('Verify Password:') + + if passwd == verify: + phash = apisrv.user.add_user(args.adduser, passwd) + if phash: + print("Successfully Added User to Database") + else: + print("Error: Could not Add User to Database") + else: + print("Error: Passwords do not match") + +# Update User Password +elif args.newpass: + passwd = getpass.getpass('New Password:') + verify = getpass.getpass('Verify Password:') + + if passwd == verify: + phash = apisrv.user.update_password(args.newpass, passwd) + if phash: + print("Successfully Updated Password") + else: + print("Error: Could not Update Password") + else: + print("Error: Passwords do not match") + +# Delete a User +elif args.deluser: + ucheck = apisrv.user.del_user(args.deluser) + + if ucheck: + print("Successfully Deleted User") + else: + print("Username not found in DB") + +# Test Authentication +elif args.testuser: + passwd = getpass.getpass('Password:') + phash = apisrv.user.authenticate_user(args.testuser, passwd) + if phash: + print("Successfully Authenticated") + else: + print("Authentication Failed") +else: + parser.print_help() + print() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3b4526 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +pylint +requests +flask +flask_httpauth +flask_limiter +passlib +sqlalchemy +flask_sqlalchemy diff --git a/runsrv.py b/runsrv.py new file mode 100755 index 0000000..92a5cd4 --- /dev/null +++ b/runsrv.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +# +""" +Run API Server +""" +import logging +from apisrv import app, debug, config + +logger = logging.getLogger('runsrv') + +# Production server in HTTPS Mode +if config['apisrv']['https'] != '0': + context = (config['apisrv']['ssl_crt'], config['apisrv']['ssl_key']) + app.run(host='0.0.0.0', port=int(config['apisrv']['port']), \ + ssl_context=context, threaded=True, debug=debug) + +# Localhost development server +else: + logger.warning("HTTPS is not configured, defaulting to localhost only") + app.run(debug=debug, port=int(config['apisrv']['port'])) diff --git a/test/rest-client.py b/test/rest-client.py new file mode 100755 index 0000000..d8237e4 --- /dev/null +++ b/test/rest-client.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +# +""" +REST API Sample Client +""" +import requests +import json + +user = 'testuser' +passwd = 'testpass' +url = 'http://localhost:5000' + +# Make a request (don't verify cert if SSL) +r = requests.get(url + '/test/api/v1.0/info?var=testvar', auth=(user, passwd), verify=False) + +# Check for proper response +if r.status_code == 200: + + # JSON Dict + response = r.json() + + # Dump JSON in pretty format + print(json.dumps(response, sort_keys=True, indent=4, separators=(',', ': '))) +else: + print("Request Error:", r.status_code, r.text) diff --git a/testapi.py b/testapi.py new file mode 100755 index 0000000..c9871aa --- /dev/null +++ b/testapi.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# Copyright (c) 2016 "Jonathan Yantis" +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License, version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# As a special exception, the copyright holders give permission to link the +# code of portions of this program with the OpenSSL library under certain +# conditions as described in each individual source file and distribute +# linked combinations including the program with the OpenSSL library. You +# must comply with the GNU Affero General Public License in all respects +# for all of the code used other than as permitted herein. If you modify +# file(s) with this exception, you may extend this exception to your +# version of the file(s), but you are not obligated to do so. If you do not +# wish to do so, delete this exception statement from your version. If you +# delete this exception statement from all source files in the program, +# then also delete it in the license file. +# +# +""" +Test the API Application +""" +import os +import apisrv +import unittest +import tempfile +from base64 import b64encode + +headers = { + 'Authorization': 'Basic %s' % b64encode(b"testuser:testpass").decode("ascii") +} + +class APITestCase(unittest.TestCase): + """ API Testing Cases""" + def setUp(self): + apisrv.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + tempfile.mkstemp()[1] + apisrv.app.config['TESTING'] = True + self.app = apisrv.app.test_client() + + with apisrv.app.app_context(): + apisrv.db.create_all() + try: + apisrv.user.add_user("testuser", "testpass") + except Exception: + pass + + def tearDown(self): + apisrv.db.drop_all() + + def test_index(self): + rv = self.app.get('/') + assert b'Authentication required' in rv.data + + def test_info(self): + rv = self.app.get('/test/api/v1.0/info?test=GETTEST', headers=headers) + assert b'GET test' in rv.data + + def test_uptime(self): + rv = self.app.get('/test/api/v1.0/uptime', headers=headers) + assert b'load averages' in rv.data + +if __name__ == '__main__': + unittest.main()