diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..0f8ed31 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,3 @@ +# CI/CD Tools and Practices Final Project GitHub Action Workflows + +This directory will contain all the GitHub Action workflows you create in the CI/CD Tools and Practices Final Project. diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5143a7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,108 @@ +# Local environment +.DS_Store +Thumbs.db + +# Vagrant +.vagrant/ + +# Test reports +unittests.xml + +# database files +db/* +!db/.keep + +# SonarQube Reports +.scannerwork/ +.sonarlint/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +.noseids +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/.tekton/README.md b/.tekton/README.md new file mode 100644 index 0000000..08f8bee --- /dev/null +++ b/.tekton/README.md @@ -0,0 +1,3 @@ +# CI/CD Tools and Practices Final Project Tekton Workflows + +This directory will contain all the Tekton workflows you create in the CI/CD Tools and Practices Final Project. diff --git a/.tekton/tasks.yml b/.tekton/tasks.yml new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..63fe48b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.9-slim + +# Establish a working folder +WORKDIR /app + +# Establish dependencies +COPY requirements.txt . +RUN python -m pip install -U pip wheel && \ + pip install -r requirements.txt + +# Copy source files last because they change the most +COPY service ./service + +# Become non-root user +RUN useradd -m -r service && \ + chown -R service:service /app +USER service + +# Run the service on port 8000 +ENV PORT 8000 +EXPOSE $PORT +CMD ["gunicorn", "service:app", "--bind", "0.0.0.0:8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..c5be6af --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn --log-file=- --workers=1 --bind=0.0.0.0:$PORT service:app diff --git a/README.md b/README.md new file mode 100644 index 0000000..342fe58 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# CI/CD Tools and Practices Final Project Template + +This repository contains the template to be used for the Final Project for the Coursera course **CI/CD Tools and Practices**. + +## Usage + +This repository is to be used as a template to create your own repository in your own GitHub account. No need to Fork it as it has been set up as a Template. This will avoid confusion when making Pull Requests in the future. + +From the GitHub **Code** page, press the green **Use this template** button to create your own repository from this template. + +Name your repo: `ci-cd-final-project`. + +## Setup + +After entering the lab environment you will need to run the `setup.sh` script in the `./bin` folder to install the prerequisite software. + +```bash +bash bin/setup.sh +``` + +Then you must exit the shell and start a new one for the Python virtual environment to be activated. + +```bash +exit +``` + +## Tasks + + +## License + +Licensed under the Apache License. See [LICENSE](/LICENSE) + +## Author + +Skills Network + +##

© IBM Corporation 2023. All rights reserved.

diff --git a/bin/setup.sh b/bin/setup.sh new file mode 100644 index 0000000..b49c15e --- /dev/null +++ b/bin/setup.sh @@ -0,0 +1,39 @@ +#!/bin/bash +echo "**************************************************" +echo " Setting up CI/CD Final Project Environment" +echo "**************************************************" + +echo "*** Installing Python 3.9 and Virtual Environment" +sudo apt-get update +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y python3.9 python3.9-venv + +echo "*** Making Python 3.9 the default..." +sudo update-alternatives --remove-all python3 +sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1 + +echo "*** Checking the Python version..." +python3 --version + +echo "*** Creating a Python virtual environment" +python3 -m venv ~/venv + +echo "*** Configuring the developer environment..." +echo "# CI/CD Final Project additions" >> ~/.bashrc +echo "export GITHUB_ACCOUNT=$GITHUB_ACCOUNT" >> ~/.bashrc +echo 'export PS1="\[\e]0;\u:\W\a\]${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u\[\033[00m\]:\[\033[01;34m\]\W\[\033[00m\]\$ "' >> ~/.bashrc +echo "source ~/venv/bin/activate" >> ~/.bashrc + +echo "*** Installing Selenium and Chrome for BDD" +sudo apt-get update +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y sqlite3 ca-certificates chromium-driver python3-selenium + +echo "*** Installing Python depenencies..." +source ~/venv/bin/activate && python3 -m pip install --upgrade pip wheel +source ~/venv/bin/activate && pip install -r requirements.txt + +echo "**************************************************" +echo " CI/CD Final Project Environment Setup Complete" +echo "**************************************************" +echo "" +echo "Use 'exit' to close this terminal and open a new one to initialize the environment" +echo "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32b733e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +# Werkzeug keeps breaking Flask! +Werkzeug==2.1.2 + +# Runtime dependencies +Flask==2.1.2 +python-dotenv==0.20.0 + +# Runtime tools +gunicorn==20.1.0 +honcho==1.1.0 + +# Code quality +pylint==2.14.0 +flake8==4.0.1 +black==22.3.0 + +# Testing dependencies +nose==1.3.7 +pinocchio==0.4.3 +coverage==6.3.2 + +# Utilities +httpie==3.2.1 diff --git a/service/__init__.py b/service/__init__.py new file mode 100644 index 0000000..98896d1 --- /dev/null +++ b/service/__init__.py @@ -0,0 +1,16 @@ +""" +Service Package +""" +from flask import Flask + +app = Flask(__name__) + +# This must be imported after the Flask app is created +from service import routes # pylint: disable=wrong-import-position,cyclic-import +from service.common import log_handlers # pylint: disable=wrong-import-position + +log_handlers.init_logging(app, "gunicorn.error") + +app.logger.info(70 * "*") +app.logger.info(" S E R V I C E R U N N I N G ".center(70, "*")) +app.logger.info(70 * "*") diff --git a/service/common/error_handlers.py b/service/common/error_handlers.py new file mode 100644 index 0000000..1108697 --- /dev/null +++ b/service/common/error_handlers.py @@ -0,0 +1,109 @@ +###################################################################### +# Copyright 2016, 2022 John J. Rofrano. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +###################################################################### + +""" +Module: error_handlers +""" +from flask import jsonify +from service import app +from . import status + + +###################################################################### +# Error Handlers +###################################################################### +@app.errorhandler(status.HTTP_400_BAD_REQUEST) +def bad_request(error): + """Handles bad requests with 400_BAD_REQUEST""" + message = str(error) + app.logger.warning(message) + return ( + jsonify( + status=status.HTTP_400_BAD_REQUEST, error="Bad Request", message=message + ), + status.HTTP_400_BAD_REQUEST, + ) + + +@app.errorhandler(status.HTTP_404_NOT_FOUND) +def not_found(error): + """Handles resources not found with 404_NOT_FOUND""" + message = str(error) + app.logger.warning(message) + return ( + jsonify(status=status.HTTP_404_NOT_FOUND, error="Not Found", message=message), + status.HTTP_404_NOT_FOUND, + ) + + +@app.errorhandler(status.HTTP_405_METHOD_NOT_ALLOWED) +def method_not_supported(error): + """Handles unsupported HTTP methods with 405_METHOD_NOT_SUPPORTED""" + message = str(error) + app.logger.warning(message) + return ( + jsonify( + status=status.HTTP_405_METHOD_NOT_ALLOWED, + error="Method not Allowed", + message=message, + ), + status.HTTP_405_METHOD_NOT_ALLOWED, + ) + + +@app.errorhandler(status.HTTP_409_CONFLICT) +def resource_conflict(error): + """Handles resource conflicts with HTTP_409_CONFLICT""" + message = str(error) + app.logger.warning(message) + return ( + jsonify( + status=status.HTTP_409_CONFLICT, + error="Conflict", + message=message, + ), + status.HTTP_409_CONFLICT, + ) + + +@app.errorhandler(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) +def mediatype_not_supported(error): + """Handles unsupported media requests with 415_UNSUPPORTED_MEDIA_TYPE""" + message = str(error) + app.logger.warning(message) + return ( + jsonify( + status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + error="Unsupported media type", + message=message, + ), + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + ) + + +@app.errorhandler(status.HTTP_500_INTERNAL_SERVER_ERROR) +def internal_server_error(error): + """Handles unexpected server error with 500_SERVER_ERROR""" + message = str(error) + app.logger.error(message) + return ( + jsonify( + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + error="Internal Server Error", + message=message, + ), + status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/service/common/log_handlers.py b/service/common/log_handlers.py new file mode 100644 index 0000000..d5c5ba6 --- /dev/null +++ b/service/common/log_handlers.py @@ -0,0 +1,38 @@ +###################################################################### +# Copyright 2016, 2022 John J. Rofrano. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +###################################################################### + +""" +Log Handlers + +This module contains utility functions to set up logging +consistently +""" +import logging + + +def init_logging(app, logger_name: str): + """Set up logging for production""" + app.logger.propagate = False + gunicorn_logger = logging.getLogger(logger_name) + app.logger.handlers = gunicorn_logger.handlers + app.logger.setLevel(gunicorn_logger.level) + # Make all log formats consistent + formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s", "%Y-%m-%d %H:%M:%S %z" + ) + for handler in app.logger.handlers: + handler.setFormatter(formatter) + app.logger.info("Logging handler established") diff --git a/service/common/status.py b/service/common/status.py new file mode 100644 index 0000000..8e6080e --- /dev/null +++ b/service/common/status.py @@ -0,0 +1,61 @@ +""" +Descriptive HTTP status codes, for code readability. +See RFC 2616 and RFC 6585. +RFC 2616: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html +RFC 6585: http://tools.ietf.org/html/rfc6585 +""" + +# Informational - 1xx +HTTP_100_CONTINUE = 100 +HTTP_101_SWITCHING_PROTOCOLS = 101 + +# Successful - 2xx +HTTP_200_OK = 200 +HTTP_201_CREATED = 201 +HTTP_202_ACCEPTED = 202 +HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 +HTTP_204_NO_CONTENT = 204 +HTTP_205_RESET_CONTENT = 205 +HTTP_206_PARTIAL_CONTENT = 206 + +# Redirection - 3xx +HTTP_300_MULTIPLE_CHOICES = 300 +HTTP_301_MOVED_PERMANENTLY = 301 +HTTP_302_FOUND = 302 +HTTP_303_SEE_OTHER = 303 +HTTP_304_NOT_MODIFIED = 304 +HTTP_305_USE_PROXY = 305 +HTTP_306_RESERVED = 306 +HTTP_307_TEMPORARY_REDIRECT = 307 + +# Client Error - 4xx +HTTP_400_BAD_REQUEST = 400 +HTTP_401_UNAUTHORIZED = 401 +HTTP_402_PAYMENT_REQUIRED = 402 +HTTP_403_FORBIDDEN = 403 +HTTP_404_NOT_FOUND = 404 +HTTP_405_METHOD_NOT_ALLOWED = 405 +HTTP_406_NOT_ACCEPTABLE = 406 +HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 +HTTP_408_REQUEST_TIMEOUT = 408 +HTTP_409_CONFLICT = 409 +HTTP_410_GONE = 410 +HTTP_411_LENGTH_REQUIRED = 411 +HTTP_412_PRECONDITION_FAILED = 412 +HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 +HTTP_414_REQUEST_URI_TOO_LONG = 414 +HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 +HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 +HTTP_417_EXPECTATION_FAILED = 417 +HTTP_428_PRECONDITION_REQUIRED = 428 +HTTP_429_TOO_MANY_REQUESTS = 429 +HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 + +# Server Error - 5xx +HTTP_500_INTERNAL_SERVER_ERROR = 500 +HTTP_501_NOT_IMPLEMENTED = 501 +HTTP_502_BAD_GATEWAY = 502 +HTTP_503_SERVICE_UNAVAILABLE = 503 +HTTP_504_GATEWAY_TIMEOUT = 504 +HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 +HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 diff --git a/service/routes.py b/service/routes.py new file mode 100644 index 0000000..f8cfa34 --- /dev/null +++ b/service/routes.py @@ -0,0 +1,122 @@ +""" +Controller for routes +""" +from flask import jsonify, url_for, abort +from service import app +from service.common import status + +COUNTER = {} + + +############################################################ +# Health Endpoint +############################################################ +@app.route("/health") +def health(): + """Health Status""" + return jsonify(dict(status="OK")), status.HTTP_200_OK + + +############################################################ +# Index page +############################################################ +@app.route("/") +def index(): + """Returns information abut the service""" + app.logger.info("Request for Base URL") + return jsonify( + status=status.HTTP_200_OK, + message="Hit Counter Service", + version="1.0.0", + url=url_for("list_counters", _external=True), + ) + + +############################################################ +# List counters +############################################################ +@app.route("/counters", methods=["GET"]) +def list_counters(): + """Lists all counters""" + app.logger.info("Request to list all counters...") + + counters = [dict(name=count[0], counter=count[1]) for count in COUNTER.items()] + + return jsonify(counters) + + +############################################################ +# Create counters +############################################################ +@app.route("/counters/", methods=["POST"]) +def create_counters(name): + """Creates a new counter""" + app.logger.info("Request to Create counter: %s...", name) + + if name in COUNTER: + return abort(status.HTTP_409_CONFLICT, f"Counter {name} already exists") + + COUNTER[name] = 0 + + location_url = url_for("read_counters", name=name, _external=True) + return ( + jsonify(name=name, counter=0), + status.HTTP_201_CREATED, + {"Location": location_url}, + ) + + +############################################################ +# Read counters +############################################################ +@app.route("/counters/", methods=["GET"]) +def read_counters(name): + """Reads a single counter""" + app.logger.info("Request to Read counter: %s...", name) + + if name not in COUNTER: + return abort(status.HTTP_404_NOT_FOUND, f"Counter {name} does not exist") + + counter = COUNTER[name] + return jsonify(name=name, counter=counter) + + +############################################################ +# Update counters +############################################################ +@app.route("/counters/", methods=["PUT"]) +def update_counters(name): + """Updates a counter""" + app.logger.info("Request to Update counter: %s...", name) + + if name not in COUNTER: + return abort(status.HTTP_404_NOT_FOUND, f"Counter {name} does not exist") + + COUNTER[name] += 1 + + counter = COUNTER[name] + return jsonify(name=name, counter=counter) + + +############################################################ +# Delete counters +############################################################ +@app.route("/counters/", methods=["DELETE"]) +def delete_counters(name): + """Deletes a counter""" + app.logger.info("Request to Delete counter: %s...", name) + + if name in COUNTER: + COUNTER.pop(name) + + return "", status.HTTP_204_NO_CONTENT + + +############################################################ +# Utility for testing +############################################################ +def reset_counters(): + """Removes all counters while testing""" + global COUNTER # pylint: disable=global-statement + if app.testing: + COUNTER = {} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2785829 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,21 @@ +[nosetests] +verbosity=2 +with-spec=1 +spec-color=1 +with-coverage=1 +cover-erase=1 +cover-package=service +# cover-xml=1 +# cover-xml-file=./coverage.xml +# with-xunit=1 +# xunit-file=./unittests.xml + +[coverage:report] +show_missing = True + +[flake8] +per-file-ignores = + */__init__.py: F401 E402 + +[pylint.'MESSAGES CONTROL'] +disable=E1101 diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..06104d8 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,132 @@ +""" +Counter API Service Test Suite + +Test cases can be run with the following: + nosetests -v --with-spec --spec-color + coverage report -m +""" +from unittest import TestCase +from service.common import status # HTTP Status Codes +from service.routes import app, reset_counters + + +###################################################################### +# T E S T C A S E S +###################################################################### +class CounterTest(TestCase): + """ REST API Server Tests """ + + @classmethod + def setUpClass(cls): + """ This runs once before the entire test suite """ + app.testing = True + + @classmethod + def tearDownClass(cls): + """ This runs once after the entire test suite """ + pass + + def setUp(self): + """ This runs before each test """ + reset_counters() + self.app = app.test_client() + + def tearDown(self): + """ This runs after each test """ + pass + +###################################################################### +# T E S T C A S E S +###################################################################### + + def test_index(self): + """ It should call the index call """ + resp = self.app.get("/") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + def test_health(self): + """ It should be healthy """ + resp = self.app.get("/health") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + def test_create_counters(self): + """ It should Create a counter """ + name = "foo" + resp = self.app.post(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + data = resp.get_json() + self.assertEqual(data["name"], name) + self.assertEqual(data["counter"], 0) + + def test_create_duplicate_counter(self): + """ It should not Create a duplicate counter """ + name = "foo" + resp = self.app.post(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + data = resp.get_json() + self.assertEqual(data["name"], name) + self.assertEqual(data["counter"], 0) + resp = self.app.post(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_409_CONFLICT) + + def test_list_counters(self): + """ It should List counters """ + resp = self.app.get("/counters") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.get_json() + self.assertEqual(len(data), 0) + # create a counter and name sure it appears in the list + self.app.post("/counters/foo") + resp = self.app.get("/counters") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.get_json() + self.assertEqual(len(data), 1) + + def test_read_counters(self): + """ It should Read a counter """ + name = "foo" + self.app.post(f"/counters/{name}") + resp = self.app.get(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.get_json() + self.assertEqual(data["name"], name) + self.assertEqual(data["counter"], 0) + + def test_update_counters(self): + """ It should Update a counter """ + name = "foo" + resp = self.app.post(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + resp = self.app.get(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.get_json() + print(data) + self.assertEqual(data["name"], name) + self.assertEqual(data["counter"], 0) + # now update it + resp = self.app.put(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.get_json() + self.assertEqual(data["name"], name) + self.assertEqual(data["counter"], 1) + + def test_update_missing_counters(self): + """ It should not Update a missing counter """ + name = "foo" + resp = self.app.put(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_counters(self): + """ It should Delete a counter """ + name = "foo" + # Create a counter + resp = self.app.post(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + # Delete it twice should return the same + resp = self.app.delete(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) + resp = self.app.delete(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT) + # Gte it to make sure it's really gone + resp = self.app.get(f"/counters/{name}") + self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)