├── .devcontainer ├── Dockerfile ├── devcontainer.json ├── docker-compose.yml └── scripts │ ├── install-tools.sh │ └── setup-lab.sh ├── .flaskenv ├── .gitattributes ├── .github └── workflows │ ├── bdd-tests.yml │ └── tdd-tests.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── Dockerfile ├── LICENSE ├── Makefile ├── Pipfile ├── Pipfile.lock ├── Procfile ├── README.md ├── Vagrantfile ├── captures └── .gitkeep ├── dot-env-example ├── features ├── environment.py ├── pets.feature └── steps │ ├── pets_steps.py │ └── web_steps.py ├── k3d-config.yaml ├── k8s ├── deployment.yaml ├── ingress.yaml ├── postgres │ ├── pv.yaml │ ├── pvc.yaml │ ├── secret.yaml │ ├── service.yaml │ └── statefulset.yaml └── service.yaml ├── service ├── __init__.py ├── common │ ├── __init__.py │ ├── cli_commands.py │ ├── error_handlers.py │ ├── log_handlers.py │ └── status.py ├── config.py ├── models.py ├── routes.py └── static │ ├── css │ ├── blue_bootstrap.min.css │ ├── cerulean_bootstrap.min.css │ ├── darkly_bootstrap.min.css │ ├── flatly_bootstrap.min.css │ └── slate_bootstrap.min.css │ ├── images │ └── newapp-icon.png │ ├── index.html │ └── js │ ├── bootstrap.min.js │ ├── jquery-3.6.0.min.js │ └── rest_api.js ├── setup.cfg ├── skaffold.env ├── skaffold.yaml ├── tests ├── __init__.py ├── factories.py ├── test_cli_commands.py ├── test_models.py └── test_routes.py └── wsgi.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # This image has selenium and chrome driver already installed 2 | FROM rofrano/pipeline-selenium:latest 3 | 4 | # Become a regular user for development 5 | ARG USERNAME=vscode 6 | USER $USERNAME 7 | 8 | # Install user mode tools 9 | COPY .devcontainer/scripts/install-tools.sh /tmp/ 10 | RUN cd /tmp; bash ./install-tools.sh 11 | 12 | # Set up the Python development environment 13 | WORKDIR /app 14 | COPY Pipfile Pipfile.lock ./ 15 | RUN python -m pip install --upgrade pip pipenv && \ 16 | pipenv install --system --dev 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // cspell:disable 2 | { 3 | "name": "Flask BDD", 4 | "dockerComposeFile": "docker-compose.yml", 5 | "service": "app", 6 | "workspaceFolder": "/app", 7 | "remoteUser": "vscode", 8 | "customizations": { 9 | "vscode": { 10 | "settings": { 11 | "cSpell.words": [ 12 | "Rofrano", 13 | "sqlalchemy", 14 | "psycopg", 15 | "wsgi", 16 | "dotenv", 17 | "pytest", 18 | "tekton", 19 | "creds", 20 | "virtualenvs" 21 | ], 22 | "[python]": { 23 | "editor.defaultFormatter": "ms-python.black-formatter", 24 | "editor.formatOnSave": true 25 | }, 26 | "git.mergeEditor": true, 27 | "markdown-preview-github-styles.colorTheme": "light", 28 | "makefile.extensionOutputFolder": "/tmp", 29 | "makefile.configureOnOpen": false, 30 | "python.testing.unittestEnabled": false, 31 | "python.testing.pytestEnabled": true, 32 | "python.testing.pytestArgs": [ 33 | "tests" 34 | ], 35 | "cucumberautocomplete.steps": ["features/steps/*.py"], 36 | "cucumberautocomplete.syncfeatures": "features/*.feature", 37 | "cucumberautocomplete.strictGherkinCompletion": true, 38 | "cucumberautocomplete.strictGherkinValidation": true, 39 | "cucumberautocomplete.smartSnippets": true, 40 | "cucumberautocomplete.gherkinDefinitionPart": "@(given|when|then)\\(", 41 | "files.exclude": { 42 | "**/.git": true, 43 | "**/.DS_Store": true, 44 | "**/*.pyc": true, 45 | "**/__pycache__": true, 46 | "**/.pytest_cache": true 47 | } 48 | }, 49 | "extensions": [ 50 | "ms-python.python", 51 | "ms-python.vscode-pylance", 52 | "ms-python.pylint", 53 | "ms-python.flake8", 54 | "ms-python.black-formatter", 55 | "njpwerner.autodocstring", 56 | "wholroyd.jinja", 57 | "ms-vscode.makefile-tools", 58 | "yzhang.markdown-all-in-one", 59 | "DavidAnson.vscode-markdownlint", 60 | "bierner.github-markdown-preview", 61 | "hnw.vscode-auto-open-markdown-preview", 62 | "bierner.markdown-preview-github-styles", 63 | "tamasfe.even-better-toml", 64 | "donjayamanne.githistory", 65 | "GitHub.vscode-pull-request-github", 66 | "github.vscode-github-actions", 67 | "hbenl.vscode-test-explorer", 68 | "LittleFoxTeam.vscode-python-test-adapter", 69 | "redhat.vscode-yaml", 70 | "unjinjang.rest-api-client", 71 | "ms-azuretools.vscode-docker", 72 | "ms-kubernetes-tools.vscode-kubernetes-tools", 73 | "inercia.vscode-k3d", 74 | "alexkrechik.cucumberautocomplete", 75 | "Zignd.html-css-class-completion", 76 | "streetsidesoftware.code-spell-checker", 77 | "bbenoist.vagrant" 78 | ] 79 | } 80 | }, 81 | // Setup the lab environment after container is created 82 | "forwardPorts": [8080], 83 | "postCreateCommand": "bash /app/.devcontainer/scripts/setup-lab.sh", 84 | "features": { 85 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}, 86 | "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {} 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Python 3 with PostgreSQL 3 | version: "3" 4 | 5 | services: 6 | app: 7 | build: 8 | context: .. 9 | dockerfile: .devcontainer/Dockerfile 10 | hostname: nyu 11 | container_name: lab-flask-bdd 12 | volumes: 13 | - ..:/app 14 | command: sleep infinity 15 | environment: 16 | FLASK_APP: wsgi:app 17 | FLASK_DEBUG: "True" 18 | GUNICORN_BIND: "0.0.0.0:8080" 19 | DATABASE_URI: postgresql+psycopg://postgres:pgs3cr3t@postgres:5432/petstore 20 | WAIT_SECONDS: 5 21 | networks: 22 | - dev 23 | depends_on: 24 | - postgres 25 | 26 | postgres: 27 | image: postgres:15-alpine 28 | # ports: 29 | # - 5432:5432 30 | environment: 31 | POSTGRES_PASSWORD: pgs3cr3t 32 | POSTGRES_DB: petstore 33 | volumes: 34 | - postgres:/var/lib/postgresql/data 35 | networks: 36 | - dev 37 | 38 | volumes: 39 | postgres: 40 | 41 | networks: 42 | dev: 43 | -------------------------------------------------------------------------------- /.devcontainer/scripts/install-tools.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ###################################################################### 3 | # These scripts are meant to be run in user mode as they modify 4 | # usr settings line .bashrc and .bash_aliases 5 | # Copyright 2022, 2023 John J. Rofrano All Rights Reserved. 6 | ###################################################################### 7 | 8 | echo "**********************************************************************" 9 | echo "Establishing Architecture..." 10 | echo "**********************************************************************" 11 | # Convert inconsistent architectures (x86_64=amd64) (aarch64=arm64) 12 | ARCH="$(uname -m | sed -e 's/x86_64/amd64/' -e 's/\(arm\)\(64\)\?.*/\1\2/' -e 's/aarch64$/arm64/')" 13 | echo "Architecture is:" $ARCH 14 | 15 | echo "**********************************************************************" 16 | echo "Installing K3D Kubernetes..." 17 | echo "**********************************************************************" 18 | curl -s "https://raw.githubusercontent.com/rancher/k3d/main/install.sh" | sudo bash 19 | echo "Creating kc and kns alias for kubectl..." 20 | echo "alias kc='/usr/local/bin/kubectl'" >> $HOME/.bash_aliases 21 | echo "alias kns='kubectl config set-context --current --namespace'" >> $HOME/.bash_aliases 22 | sudo sh -c 'echo "127.0.0.1 cluster-registry" >> /etc/hosts' 23 | 24 | echo "**********************************************************************" 25 | echo "Installing K9s..." 26 | echo "**********************************************************************" 27 | curl -L -o k9s.tar.gz "https://github.com/derailed/k9s/releases/download/v0.32.5/k9s_Linux_$ARCH.tar.gz" 28 | tar xvzf k9s.tar.gz 29 | sudo install -c -m 0755 k9s /usr/local/bin 30 | rm k9s.tar.gz 31 | 32 | echo "**********************************************************************" 33 | echo "Installing Skaffold..." 34 | echo "**********************************************************************" 35 | curl -Lo skaffold "https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-$ARCH" 36 | sudo install skaffold /usr/local/bin/ 37 | 38 | echo "**********************************************************************" 39 | echo "Installing DevSpace..." 40 | echo "**********************************************************************" 41 | curl -Lo devspace "https://github.com/loft-sh/devspace/releases/latest/download/devspace-linux-$ARCH" 42 | sudo install -c -m 0755 devspace /usr/local/bin 43 | -------------------------------------------------------------------------------- /.devcontainer/scripts/setup-lab.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Setup the lab environment after container is started using: 4 | # "postCreateCommand": "bash /app/.devcontainer/scripts/setup-lab.sh" 5 | # 6 | echo "**********************************************************************" 7 | echo "Setting up Flask BDD lab environment..." 8 | echo "**********************************************************************\n" 9 | 10 | # Create .env file if it doesn't exist 11 | if [ ! -f .env ]; then 12 | cp dot-env-example .env 13 | fi 14 | 15 | # Pull container images for the lab 16 | docker pull python:3.11-slim 17 | 18 | # Modify /etc/hosts to map the local container registry 19 | echo Setting up cluster-registry... 20 | sudo bash -c "echo '127.0.0.1 cluster-registry' >> /etc/hosts" 21 | 22 | # Make git stop complaining about unsafe folders 23 | git config --global --add safe.directory /app 24 | 25 | echo "\n**********************************************************************" 26 | echo "Setup complete" 27 | echo "**********************************************************************" 28 | -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_RUN_PORT=8080 2 | FLASK_APP=service:app 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.github/workflows/bdd-tests.yml: -------------------------------------------------------------------------------- 1 | name: BDD Tests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths-ignore: 7 | - 'README.md' 8 | - '.vscode/**' 9 | - '**.md' 10 | pull_request: 11 | branches: 12 | - master 13 | paths-ignore: 14 | - 'README.md' 15 | - '.vscode/**' 16 | - '**.md' 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | container: quay.io/rofrano/pipeline-selenium:sp25 22 | 23 | services: 24 | postgres: 25 | image: postgres:15-alpine 26 | env: 27 | POSTGRES_PASSWORD: pgs3cr3t 28 | POSTGRES_DB: testdb 29 | ports: 30 | - 5432:5432 31 | # Set health checks to wait until postgres has started 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v3 41 | 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install -U pip pipenv 45 | pipenv install --system --dev 46 | 47 | - name: Run the service locally 48 | run: | 49 | echo "\n*** STARTING APPLICATION ***\n" 50 | gunicorn --log-level=info --bind=0.0.0.0:8080 wsgi:app & 51 | echo "Waiting for service to stabilize..." 52 | sleep 5 53 | echo "Checking service /health..." 54 | curl -i http://localhost:8080/health 55 | echo "\n*** SERVER IS RUNNING ***" 56 | env: 57 | DATABASE_URI: "postgresql+psycopg://postgres:pgs3cr3t@postgres:5432/testdb" 58 | 59 | - name: Run Integration Tests 60 | run: behave 61 | env: 62 | DRIVER: "chrome" 63 | 64 | -------------------------------------------------------------------------------- /.github/workflows/tdd-tests.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths-ignore: 7 | - 'README.md' 8 | - '.vscode/**' 9 | - '**.md' 10 | pull_request: 11 | branches: 12 | - master 13 | paths-ignore: 14 | - 'README.md' 15 | - '.vscode/**' 16 | - '**.md' 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | container: python:3.11-slim 22 | 23 | services: 24 | postgres: 25 | image: postgres:15-alpine 26 | env: 27 | POSTGRES_PASSWORD: pgs3cr3t 28 | POSTGRES_DB: testdb 29 | ports: 30 | - 5432:5432 31 | # Set health checks to wait until postgres has started 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | 38 | # Steps for the build 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v3 42 | 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install -U pip pipenv 46 | pipenv install --system --dev 47 | 48 | - name: Run Code Quality Checks 49 | run: | 50 | # stop the build if there are Python syntax errors or undefined names 51 | flake8 service tests --count --select=E9,F63,F7,F82 --show-source --statistics 52 | # check for complexity. The GitHub editor is 127 chars wide 53 | flake8 service tests --count --max-complexity=10 --max-line-length=127 --statistics 54 | # Run pylint on the service 55 | pylint service tests --max-line-length=127 56 | 57 | - name: Run unit tests with pytest 58 | run: | 59 | pytest --pspec --cov=service --cov-fail-under=95 --disable-warnings --cov-report=xml 60 | env: 61 | FLASK_APP: "wsgi:app" 62 | DATABASE_URI: "postgresql+psycopg://postgres:pgs3cr3t@postgres:5432/testdb" 63 | 64 | - name: Upload code coverage 65 | uses: codecov/codecov-action@v3.1.4 66 | with: 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local environment 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Vagrant 6 | .vagrant/ 7 | 8 | # Test reports 9 | unittests.xml 10 | 11 | # database files 12 | db/* 13 | !db/.keep 14 | 15 | # SonarQube Reports 16 | .scannerwork/ 17 | .sonarlint/ 18 | 19 | # Byte-compiled / optimized / DLL files 20 | __pycache__/ 21 | *.py[cod] 22 | *$py.class 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | env/ 30 | build/ 31 | develop-eggs/ 32 | dist/ 33 | downloads/ 34 | eggs/ 35 | .eggs/ 36 | lib/ 37 | lib64/ 38 | parts/ 39 | sdist/ 40 | var/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | .noseids 63 | coverage.xml 64 | *,cover 65 | .hypothesis/ 66 | 67 | # Translations 68 | *.mo 69 | *.pot 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # IPython Notebook 89 | .ipynb_checkpoints 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # dotenv 98 | .env 99 | 100 | # virtualenv 101 | venv/ 102 | ENV/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Python: Flask", 5 | "type": "python", 6 | "request": "launch", 7 | "module": "flask", 8 | "env": { 9 | "FLASK_APP": "wsgi:app", 10 | "FLASK_ENV": "development" 11 | }, 12 | "args": [ 13 | "run", 14 | "--no-debugger" 15 | ], 16 | "jinja": true, 17 | "justMyCode": true 18 | } 19 | ] 20 | }, -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Test tasks for NYU course DevOps and Agile Methodologies taught by John Rofrano 2 | // Copyright Shuhong Cai 2021. Licensed under the Apache License, Version 2.0 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "TDD tests", 8 | "type": "shell", 9 | "command": "pytest", 10 | "group": "test", 11 | "presentation": { 12 | "reveal": "always", 13 | "panel": "dedicated", 14 | } 15 | }, 16 | { 17 | "label": "BDD tests", 18 | "type": "shell", 19 | "command": "honcho start >/dev/null 2>&1 & sleep 5 && behave; kill %%", 20 | "group": "test", 21 | "presentation": { 22 | "reveal": "always", 23 | "panel": "dedicated", 24 | } 25 | }, 26 | { 27 | "label": "TDD & BDD tests", 28 | "type": "shell", 29 | "group": "test", 30 | "dependsOrder": "sequence", 31 | "dependsOn": ["TDD tests", "BDD tests"], 32 | "presentation": { 33 | "reveal": "never", 34 | } 35 | }, 36 | ] 37 | 38 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ################################################## 2 | # Create production image 3 | ################################################## 4 | # cSpell: disable 5 | FROM quay.io/rofrano/python:3.11-slim 6 | 7 | # Set up the Python production environment 8 | WORKDIR /app 9 | COPY Pipfile Pipfile.lock ./ 10 | RUN python -m pip install --upgrade pip pipenv && \ 11 | pipenv install --system --deploy 12 | 13 | # Copy the application contents 14 | COPY wsgi.py . 15 | COPY service ./service 16 | 17 | # Switch to a non-root user and set file ownership 18 | RUN useradd --uid 1001 flask && \ 19 | chown -R flask:flask /app 20 | USER flask 21 | 22 | # Expose any ports the app is expecting in the environment 23 | ENV FLASK_APP=wsgi:app 24 | ENV PORT=8080 25 | EXPOSE $PORT 26 | 27 | ENV GUNICORN_BIND=0.0.0.0:$PORT 28 | ENTRYPOINT ["gunicorn"] 29 | CMD ["--log-level=info", "wsgi:app"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # These can be overidden with env vars. 2 | REGISTRY ?= cluster-registry:5000 3 | NAMESPACE ?= nyu-devops 4 | IMAGE_NAME ?= petshop 5 | IMAGE_TAG ?= 1.0.0 6 | IMAGE ?= $(REGISTRY)/$(NAMESPACE)/$(IMAGE_NAME):$(IMAGE_TAG) 7 | PLATFORM ?= "linux/amd64,linux/arm64" 8 | CLUSTER ?= nyu-devops 9 | 10 | .SILENT: 11 | 12 | .PHONY: help 13 | help: ## Display this help. 14 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 15 | 16 | .PHONY: all 17 | all: help 18 | 19 | ##@ Development 20 | 21 | .PHONY: clean 22 | clean: ## Removes all dangling build cache 23 | $(info Removing all dangling build cache..) 24 | -docker rmi $(IMAGE) 25 | docker image prune -f 26 | docker buildx prune -f 27 | 28 | venv: ## Create a Python virtual environment 29 | $(info Creating Python 3 virtual environment...) 30 | pipenv shell 31 | 32 | install: ## Install Python dependencies 33 | $(info Installing dependencies...) 34 | sudo pipenv install --system --dev 35 | 36 | .PHONY: lint 37 | lint: ## Run the linter 38 | $(info Running linting...) 39 | -flake8 service tests --count --select=E9,F63,F7,F82 --show-source --statistics 40 | -flake8 service tests --count --max-complexity=10 --max-line-length=127 --statistics 41 | -pylint service tests --max-line-length=127 42 | 43 | .PHONY: test 44 | test: ## Run the unit tests 45 | $(info Running tests...) 46 | export RETRY_COUNT=1; pytest --disable-warnings 47 | 48 | .PHONY: run 49 | run: ## Run the service 50 | $(info Starting service...) 51 | honcho start 52 | 53 | .PHONY: secret 54 | secret: ## Generate a secret hex key 55 | $(info Generating a new secret key...) 56 | python3 -c 'import secrets; print(secrets.token_hex())' 57 | 58 | ##@ Kubernetes 59 | 60 | .PHONY: cluster 61 | cluster: ## Create a K3D Kubernetes cluster with load balancer and registry 62 | $(info Creating Kubernetes cluster with a registry and 1 node...) 63 | k3d cluster create $(CLUSTER) --agents 1 --registry-create cluster-registry:0.0.0.0:5000 --port '8080:80@loadbalancer' 64 | 65 | .PHONY: cluster-rm 66 | cluster-rm: ## Remove a K3D Kubernetes cluster 67 | $(info Removing Kubernetes cluster...) 68 | k3d cluster delete $(CLUSTER) 69 | 70 | ##@ Deploy 71 | 72 | .PHONY: push 73 | push: ## Push to a Docker image registry 74 | $(info Logging into IBM Cloud cluster $(CLUSTER)...) 75 | docker push $(IMAGE) 76 | 77 | .PHONY: postgres 78 | postgres: ## Deploy the PostgreSQL service on local Kubernetes 79 | $(info Deploying PostgreSQL service to Kubernetes...) 80 | kubectl apply -f k8s/postgres 81 | 82 | .PHONY: deploy 83 | deploy: postgres ## Deploy the service on local Kubernetes 84 | $(info Deploying service locally...) 85 | kubectl apply -f k8s/ 86 | 87 | .PHONY: undeploy 88 | undeploy: ## Delete the deployment on local Kubernetes 89 | $(info Removing service on Kubernetes...) 90 | kubectl delete -f k8s/ 91 | 92 | ############################################################ 93 | # COMMANDS FOR BUILDING THE IMAGE 94 | ############################################################ 95 | 96 | ##@ Image Build 97 | 98 | .PHONY: init 99 | init: export DOCKER_BUILDKIT=1 100 | init: ## Creates the buildx instance 101 | $(info Initializing Builder...) 102 | -docker buildx create --use --name=qemu 103 | docker buildx inspect --bootstrap 104 | 105 | .PHONY: build 106 | build: ## Build all of the project Docker images 107 | $(info Building $(IMAGE) for $(PLATFORM)...) 108 | docker build --rm --pull --tag $(IMAGE) . 109 | 110 | .PHONY: buildx 111 | buildx: ## Build multi-platform image with buildx 112 | $(info Building multi-platform image $(IMAGE) for $(PLATFORM)...) 113 | docker buildx build --file Dockerfile --pull --platform=$(PLATFORM) --tag $(IMAGE) --push . 114 | 115 | .PHONY: remove 116 | remove: ## Stop and remove the buildx builder 117 | $(info Stopping and removing the builder image...) 118 | docker buildx stop 119 | docker buildx rm 120 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | flask = "~=3.1.0" 8 | flask-sqlalchemy = "~=3.1.1" 9 | psycopg = {extras = ["binary"], version = "~=3.2.4"} 10 | retry2 = "~=0.9.5" 11 | python-dotenv = "~=1.0.1" 12 | gunicorn = "~=23.0.0" 13 | 14 | [dev-packages] 15 | honcho = "~=2.0.0" 16 | httpie = "~=3.2.4" 17 | 18 | # Code Quality 19 | pylint = "~=3.3.4" 20 | flake8 = "~=7.1.1" 21 | black = "~=25.1.0" 22 | 23 | # Test-Driven Development 24 | pytest = "~=8.3.4" 25 | pytest-pspec = "~=0.0.4" 26 | pytest-cov = "~=6.0.0" 27 | factory-boy = "~=3.3.3" 28 | coverage = "~=7.6.12" 29 | 30 | # Behavior-Driven Development 31 | behave = "~=1.2.6" 32 | selenium = "==4.16.0" # newer versions do not work 33 | requests = "~=2.31.0" 34 | compare3 = "~=1.0.4" 35 | 36 | [requires] 37 | python_version = "3.11" 38 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn --workers=1 --bind 0.0.0.0:$PORT --log-level=info wsgi:app 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lab: Python Flask Behavior Driven Development 2 | 3 | [![Build Status](https://github.com/nyu-devops/lab-flask-bdd/actions/workflows/tdd-tests.yml/badge.svg)](https://github.com/nyu-devops/lab-flask-bdd/actions) 4 | [![Build Status](https://github.com/nyu-devops/lab-flask-bdd/actions/workflows/bdd-tests.yml/badge.svg)](https://github.com/nyu-devops/lab-flask-bdd/actions) 5 | [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/nyu-devops/lab-flask-bdd) 6 | 7 | This repository is a lab from the *NYU DevOps and Agile Methodologies* graduate course [CSCI-GA.2820-001](https://cs.nyu.edu/courses/fall22/CSCI-GA.2820-001/) on Behavior Driven Development with Flask and Behave 8 | 9 | The sample code is using [Flask micro-framework](http://flask.pocoo.org/) and is intended to be deployed to Kubernetes using [K3D](https://k3d.io). It also uses [PostgreSQL](https://www.postgresql.org) as a database. 10 | 11 | ## Introduction 12 | 13 | One of my favorite quotes is: 14 | 15 | *"If it's worth building, it's worth testing. 16 | If it's not worth testing, why are you wasting your time working on it?"* 17 | 18 | As Software Engineers we need to have the discipline to ensure that our code works as expected and continues to do so regardless of any changes, refactoring, or the introduction of new functionality. 19 | 20 | This lab introduces **Test Driven Development** using `PyUnit` and `PyTest`. It also explores the use of using RSpec syntax with Python through the introduction of the `compare` library that introduces the `expects` statement to make test cases more readable. 21 | 22 | It also introduces **Behavior Driven Development** using `Behave` as a way to define Acceptance Tests that customer can understand and developers can execute! 23 | 24 | ## Prerequisite Software Installation 25 | 26 | This lab uses Docker and Visual Studio Code with the Remote Containers extension to provide a consistent repeatable disposable development environment for all of the labs in this course. 27 | 28 | You will need the following software installed: 29 | 30 | - [Docker Desktop](https://www.docker.com/products/docker-desktop) 31 | - [Visual Studio Code](https://code.visualstudio.com) 32 | - [Remote Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension from the Visual Studio Marketplace 33 | 34 | All of these can be installed manually by clicking on the links above or you can use a package manager like **Homebrew** on Mac of **Chocolatey** on Windows. 35 | 36 | Alternately, you can use [Vagrant](https://www.vagrantup.com/) and [VirtualBox](https://www.virtualbox.org/) to create a consistent development environment in a virtual machine (VM). 37 | 38 | You can read more about creating these environments in my article: [Creating Reproducible Development Environments](https://johnrofrano.medium.com/creating-reproducible-development-environments-fac8d6471f35) 39 | 40 | ## Bring up the development environment 41 | 42 | To bring up the development environment you should clone this repo, change into the repo directory: 43 | 44 | ```bash 45 | git clone https://github.com/nyu-devops/lab-flask-bdd.git 46 | cd lab-flask-bdd 47 | ``` 48 | 49 | Depending on which development environment you created, pick from the following: 50 | 51 | ### Start developing with Visual Studio Code and Docker 52 | 53 | Open Visual Studio Code using the `code .` command. VS Code will prompt you to reopen in a container and you should say **yes**. This will take a while as it builds the Docker image and creates a container from it to develop in. 54 | 55 | ```bash 56 | code . 57 | ``` 58 | 59 | Note that there is a period `.` after the `code` command. This tells Visual Studio Code to open the editor and load the current folder of files. 60 | 61 | Once the environment is loaded you should be placed at a `bash` prompt in the `/app` folder inside of the development container. This folder is mounted to the current working directory of your repository on your computer. This means that any file you edit while inside of the `/app` folder in the container is actually being edited on your computer. You can then commit your changes to `git` from either inside or outside of the container. 62 | 63 | ## Manually running the Tests 64 | 65 | This repository has both unit tests and integration tests. You can now run `pytest` and `behave` to run the TDD and BDD tests respectively. (*see below: Behave requires the service under test to be running*) 66 | 67 | ### Test Driven Development (TDD) 68 | 69 | This repo also has unit tests that you can run `pytest` 70 | 71 | ```sh 72 | pytest 73 | ``` 74 | 75 | Pytest is configured to automatically include the flags `--pspec --cov=service --cov-fail-under=95 --disable-warnings` so that red-green-refactor is meaningful. If you are in a command shell that supports colors, passing tests will be green while failing tests will be red using the `pytest-pspec` plugin. You will also see a code coverage report at the end which uses the `pytest-cov` and `coverage` plugin. 76 | 77 | ### Behavior Driven Development (BDD) 78 | 79 | These tests require the service to be running because unlike the the TDD unit tests that test the code locally, these BDD integration tests are using Selenium to manipulate a web page on a running server. 80 | 81 | #### Run using two shells 82 | 83 | Start the server in a separate bash shell: 84 | 85 | ```sh 86 | honcho start 87 | ``` 88 | 89 | Then start `behave` in your original bash shell: 90 | 91 | ```sh 92 | behave 93 | ``` 94 | 95 | You will see the results of the tests scroll down yur screen using the familiar red/green/refactor colors. 96 | 97 | #### Run using Kubernetes 98 | 99 | You can also use Kubernetes to host your application and test against it with BDD. The commands to do this are: 100 | 101 | ```bash 102 | make cluster 103 | make build 104 | make push 105 | make deploy 106 | ``` 107 | 108 | What did these commands do? 109 | 110 | | Command | What does it do? | 111 | |---------|------------------| 112 | | make cluster | Creates a local Kubernetes cluster using `k3d` | 113 | | make build | Builds the Docker image | 114 | | make push | Pushes the image to the local Docker registry | 115 | | make deploy | Deploys the application using the image that was just built and pushed | 116 | 117 | Now you can just run `behave` against the application running in the local Kubernetes cluster 118 | 119 | ```bash 120 | behave 121 | ``` 122 | 123 | ### See what images are in the local registry 124 | 125 | You can use the `curl` command to query what images you have pushed to your local Docker registry. This will return `JSON` so you might want to use the silent flag `-s` and pipe it through `jq` like this: 126 | 127 | ```bash 128 | curl -XGET http://localhost:5000/v2/_catalog -s | jq 129 | ``` 130 | 131 | That will return all of the image names without the tags. 132 | 133 | To get the tags use: 134 | 135 | ```bash 136 | curl -XGET http://localhost:5000/v2//tags/list -s | jq 137 | ``` 138 | 139 | ## What's featured in the project? 140 | 141 | ```text 142 | ./service/routes.py -- the main Service using Python Flask 143 | ./service/models.py -- the data models for persistence 144 | ./service/common -- a collection of status, error handlers and logging setup 145 | ./tests/test_routes.py -- unit test cases for the server 146 | ./tests/test_models.py -- unit test cases for the model 147 | ./features/pets.feature -- Behave feature file 148 | ./features/steps/web_steps.py -- Behave step definitions 149 | ``` 150 | 151 | ## License 152 | 153 | Copyright (c) 2016, 2024, John J. Rofrano. All rights reserved. 154 | 155 | Licensed under the Apache License. See [LICENSE](LICENSE) 156 | 157 | This repository is part of the NYU graduate class **CSCI-GA.2810-001: DevOps and Agile Methodologies** taught by [John Rofrano](http://cs.nyu.edu/~rofrano/), Adjunct Instructor, NYU Courant Institute, Graduate Division, Computer Science. 158 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure(2) do |config| 5 | # Chrome driver doesn't work with bento box 6 | # config.vm.box = "ubuntu/focal64" 7 | config.vm.box = "bento/ubuntu-21.04" 8 | config.vm.hostname = "ubuntu" 9 | 10 | # set up network ip and port forwarding 11 | config.vm.network "forwarded_port", guest: 8080, host: 8080, host_ip: "127.0.0.1" 12 | config.vm.network "forwarded_port", guest: 5984, host: 5984, host_ip: "127.0.0.1" 13 | config.vm.network "private_network", ip: "192.168.56.10" 14 | 15 | # Windows users need to change the permissions explicitly so that Windows doesn't 16 | # set the execute bit on all of your files which messes with GitHub users on Mac and Linux 17 | config.vm.synced_folder "./", "/vagrant", owner: "vagrant", mount_options: ["dmode=755,fmode=644"] 18 | 19 | ############################################################ 20 | # Provider for VirtualBox 21 | ############################################################ 22 | config.vm.provider "virtualbox" do |vb| 23 | # Customize the amount of memory on the VM: 24 | vb.memory = "1024" 25 | vb.cpus = 1 26 | # Fixes some DNS issues on some networks 27 | vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] 28 | vb.customize ["modifyvm", :id, "--natdnsproxy1", "on"] 29 | end 30 | 31 | ############################################################ 32 | # Provider for Docker 33 | ############################################################ 34 | config.vm.provider :docker do |docker, override| 35 | override.vm.box = nil 36 | # Chromium driver does not work with ubuntu so we use debian 37 | override.vm.hostname = "debian" 38 | docker.image = "rofrano/vagrant-provider:debian" 39 | docker.remains_running = true 40 | docker.has_ssh = true 41 | docker.privileged = true 42 | docker.volumes = ["/sys/fs/cgroup:/sys/fs/cgroup:rw"] 43 | docker.create_args = ["--cgroupns=host"] 44 | # Uncomment to force arm64 for testing images on Intel 45 | # docker.create_args = ["--cgroupns=host", "--platform=linux/arm64"] 46 | end 47 | 48 | # Copy your .gitconfig file so that your git credentials are correct 49 | if File.exists?(File.expand_path("~/.gitconfig")) 50 | config.vm.provision "file", source: "~/.gitconfig", destination: "~/.gitconfig" 51 | end 52 | 53 | # Copy your ssh keys for github so that your git credentials work 54 | if File.exists?(File.expand_path("~/.ssh/id_rsa")) 55 | config.vm.provision "file", source: "~/.ssh/id_rsa", destination: "~/.ssh/id_rsa" 56 | end 57 | 58 | # Copy your IBM Clouid API Key if you have one 59 | if File.exists?(File.expand_path("~/.bluemix/apikey.json")) 60 | config.vm.provision "file", source: "~/.bluemix/apikey.json", destination: "~/.bluemix/apikey.json" 61 | end 62 | 63 | # Copy your .vimrc file so that your VI editor looks right 64 | if File.exists?(File.expand_path("~/.vimrc")) 65 | config.vm.provision "file", source: "~/.vimrc", destination: "~/.vimrc" 66 | end 67 | 68 | ###################################################################### 69 | # Setup a Python 3 development environment 70 | ###################################################################### 71 | config.vm.provision "shell", inline: <<-SHELL 72 | apt-get update 73 | apt-get install -y git tree wget vim jq python3-dev python3-pip python3-venv python3-selenium 74 | apt-get -y autoremove 75 | 76 | # Install Chromium Driver 77 | apt-get install -y chromium-driver 78 | 79 | # Create a Python3 Virtual Environment and Activate it in .profile 80 | sudo -H -u vagrant sh -c 'python3 -m venv ~/venv' 81 | sudo -H -u vagrant sh -c 'echo ". ~/venv/bin/activate" >> ~/.profile' 82 | 83 | # Install app dependencies 84 | sudo -H -u vagrant sh -c '. ~/venv/bin/activate && pip install -U pip && pip install wheel' 85 | sudo -H -u vagrant sh -c '. ~/venv/bin/activate && cd /vagrant && pip install -r requirements.txt' 86 | SHELL 87 | 88 | ###################################################################### 89 | # Add PostgreSQL docker container 90 | ###################################################################### 91 | # docker run -d --name postgres -p 5432:5432 -v psql_data:/var/lib/postgresql/data postgres 92 | config.vm.provision :docker do |d| 93 | d.pull_images "postgres:alpine" 94 | d.run "postgres:alpine", 95 | args: "-d --name postgres -p 5432:5432 -v psql_data:/var/lib/postgresql/data -e POSTGRES_PASSWORD=postgres" 96 | end 97 | 98 | ###################################################################### 99 | # Add a test database after Postgres is provisioned 100 | ###################################################################### 101 | config.vm.provision "shell", inline: <<-SHELL 102 | # Create testdb database using postgres cli 103 | echo "Pausing for 60 seconds to allow PostgreSQL to initialize..." 104 | sleep 60 105 | echo "Creating test database" 106 | docker exec postgres psql -c "create database testdb;" -U postgres 107 | # Done 108 | SHELL 109 | 110 | ###################################################################### 111 | # Setup a Bluemix and Kubernetes environment 112 | ###################################################################### 113 | config.vm.provision "shell", inline: <<-SHELL 114 | echo "\n************************************" 115 | echo " Installing IBM Cloud CLI..." 116 | echo "************************************\n" 117 | # WARNING!!! This only works on Intel computers 118 | # Install IBM Cloud CLI as Vagrant user 119 | curl -fsSL https://clis.cloud.ibm.com/install/linux | sh 120 | sudo -H -u vagrant bash -c 'echo "alias ic=/usr/local/bin/ibmcloud" >> ~/.bash_aliases' 121 | 122 | echo "\n************************************" 123 | echo "" 124 | echo "If you have an IBM Cloud API key in ~/.bluemix/apikey.json" 125 | echo "You can login with the following command:" 126 | echo "" 127 | echo "ibmcloud login -a https://cloud.ibm.com --apikey @~/.bluemix/apikey.json -r us-south" 128 | echo "\nibmcloud target --cf" 129 | echo "\n************************************" 130 | SHELL 131 | 132 | end 133 | -------------------------------------------------------------------------------- /captures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyu-devops/lab-flask-bdd/79d378392ac8fd48c36494bfddc5d5bcd426cbdf/captures/.gitkeep -------------------------------------------------------------------------------- /dot-env-example: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | FLASK_APP=wsgi:app 3 | WAIT_SECONDS=5 4 | -------------------------------------------------------------------------------- /features/environment.py: -------------------------------------------------------------------------------- 1 | """ 2 | Environment for Behave Testing 3 | """ 4 | 5 | from os import getenv 6 | from selenium import webdriver 7 | 8 | WAIT_SECONDS = int(getenv("WAIT_SECONDS", "30")) 9 | BASE_URL = getenv("BASE_URL", "http://localhost:8080") 10 | DRIVER = getenv("DRIVER", "chrome").lower() 11 | 12 | 13 | def before_all(context): 14 | """Executed once before all tests""" 15 | context.base_url = BASE_URL 16 | context.wait_seconds = WAIT_SECONDS 17 | # Select either Chrome or Firefox 18 | if "firefox" in DRIVER: 19 | context.driver = get_firefox() 20 | else: 21 | context.driver = get_chrome() 22 | context.driver.implicitly_wait(context.wait_seconds) 23 | context.driver.set_window_size(1280, 1300) 24 | context.config.setup_logging() 25 | 26 | 27 | def after_all(context): 28 | """Executed after all tests""" 29 | context.driver.quit() 30 | 31 | 32 | ###################################################################### 33 | # Utility functions to create web drivers 34 | ###################################################################### 35 | 36 | 37 | def get_chrome(): 38 | """Creates a headless Chrome driver""" 39 | print("Running Behave using the Chrome driver...\n") 40 | options = webdriver.ChromeOptions() 41 | options.add_argument("--no-sandbox") 42 | options.add_argument("--disable-dev-shm-usage") 43 | options.add_argument("--headless") 44 | return webdriver.Chrome(options=options) 45 | 46 | 47 | def get_firefox(): 48 | """Creates a headless Firefox driver""" 49 | print("Running Behave using the Firefox driver...\n") 50 | options = webdriver.FirefoxOptions() 51 | options.add_argument("--no-sandbox") 52 | options.add_argument("--disable-dev-shm-usage") 53 | options.add_argument("--headless") 54 | return webdriver.Firefox(options=options) 55 | -------------------------------------------------------------------------------- /features/pets.feature: -------------------------------------------------------------------------------- 1 | Feature: The pet store service back-end 2 | As a Pet Store Owner 3 | I need a RESTful catalog service 4 | So that I can keep track of all my pets 5 | 6 | Background: 7 | Given the following pets 8 | | name | category | available | gender | birthday | 9 | | fido | dog | True | MALE | 2019-11-18 | 10 | | kitty | cat | True | FEMALE | 2020-08-13 | 11 | | leo | lion | False | MALE | 2021-04-01 | 12 | | sammy | snake | True | UNKNOWN | 2018-06-04 | 13 | 14 | Scenario: The server is running 15 | When I visit the "Home Page" 16 | Then I should see "Pet Demo RESTful Service" in the title 17 | And I should not see "404 Not Found" 18 | 19 | Scenario: Create a Pet 20 | When I visit the "Home Page" 21 | And I set the "Name" to "Happy" 22 | And I set the "Category" to "Hippo" 23 | And I select "False" in the "Available" dropdown 24 | And I select "Male" in the "Gender" dropdown 25 | And I set the "Birthday" to "06-16-2022" 26 | And I press the "Create" button 27 | Then I should see the message "Success" 28 | When I copy the "Id" field 29 | And I press the "Clear" button 30 | Then the "Id" field should be empty 31 | And the "Name" field should be empty 32 | And the "Category" field should be empty 33 | When I paste the "Id" field 34 | And I press the "Retrieve" button 35 | Then I should see the message "Success" 36 | And I should see "Happy" in the "Name" field 37 | And I should see "Hippo" in the "Category" field 38 | And I should see "False" in the "Available" dropdown 39 | And I should see "Male" in the "Gender" dropdown 40 | And I should see "2022-06-16" in the "Birthday" field 41 | 42 | Scenario: List all pets 43 | When I visit the "Home Page" 44 | And I press the "Search" button 45 | Then I should see the message "Success" 46 | And I should see "fido" in the results 47 | And I should see "kitty" in the results 48 | And I should not see "leo" in the results 49 | 50 | Scenario: Search for dogs 51 | When I visit the "Home Page" 52 | And I set the "Category" to "dog" 53 | And I press the "Search" button 54 | Then I should see the message "Success" 55 | And I should see "fido" in the results 56 | And I should not see "kitty" in the results 57 | And I should not see "leo" in the results 58 | 59 | Scenario: Search for available 60 | When I visit the "Home Page" 61 | And I select "True" in the "Available" dropdown 62 | And I press the "Search" button 63 | Then I should see the message "Success" 64 | And I should see "fido" in the results 65 | And I should see "kitty" in the results 66 | And I should see "sammy" in the results 67 | And I should not see "leo" in the results 68 | 69 | Scenario: Update a Pet 70 | When I visit the "Home Page" 71 | And I set the "Name" to "fido" 72 | And I press the "Search" button 73 | Then I should see the message "Success" 74 | And I should see "fido" in the "Name" field 75 | And I should see "dog" in the "Category" field 76 | When I change "Name" to "Loki" 77 | And I press the "Update" button 78 | Then I should see the message "Success" 79 | When I copy the "Id" field 80 | And I press the "Clear" button 81 | And I paste the "Id" field 82 | And I press the "Retrieve" button 83 | Then I should see the message "Success" 84 | And I should see "Loki" in the "Name" field 85 | When I press the "Clear" button 86 | And I press the "Search" button 87 | Then I should see the message "Success" 88 | And I should see "Loki" in the results 89 | And I should not see "fido" in the results 90 | -------------------------------------------------------------------------------- /features/steps/pets_steps.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # Copyright 2016, 2024 John J. Rofrano. All Rights Reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | ###################################################################### 16 | 17 | """ 18 | Pet Steps 19 | 20 | Steps file for Pet.feature 21 | 22 | For information on Waiting until elements are present in the HTML see: 23 | https://selenium-python.readthedocs.io/waits.html 24 | """ 25 | import requests 26 | from compare3 import expect 27 | from behave import given # pylint: disable=no-name-in-module 28 | 29 | # HTTP Return Codes 30 | HTTP_200_OK = 200 31 | HTTP_201_CREATED = 201 32 | HTTP_204_NO_CONTENT = 204 33 | 34 | WAIT_TIMEOUT = 60 35 | 36 | 37 | @given('the following pets') 38 | def step_impl(context): 39 | """ Delete all Pets and load new ones """ 40 | 41 | # Get a list all of the pets 42 | rest_endpoint = f"{context.base_url}/pets" 43 | context.resp = requests.get(rest_endpoint, timeout=WAIT_TIMEOUT) 44 | expect(context.resp.status_code).equal_to(HTTP_200_OK) 45 | # and delete them one by one 46 | for pet in context.resp.json(): 47 | context.resp = requests.delete(f"{rest_endpoint}/{pet['id']}", timeout=WAIT_TIMEOUT) 48 | expect(context.resp.status_code).equal_to(HTTP_204_NO_CONTENT) 49 | 50 | # load the database with new pets 51 | for row in context.table: 52 | payload = { 53 | "name": row['name'], 54 | "category": row['category'], 55 | "available": row['available'] in ['True', 'true', '1'], 56 | "gender": row['gender'], 57 | "birthday": row['birthday'] 58 | } 59 | context.resp = requests.post(rest_endpoint, json=payload, timeout=WAIT_TIMEOUT) 60 | expect(context.resp.status_code).equal_to(HTTP_201_CREATED) 61 | -------------------------------------------------------------------------------- /features/steps/web_steps.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # Copyright 2016, 2024 John J. Rofrano. All Rights Reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | ###################################################################### 16 | 17 | # pylint: disable=function-redefined, missing-function-docstring 18 | # flake8: noqa 19 | """ 20 | Web Steps 21 | 22 | Steps file for web interactions with Selenium 23 | 24 | For information on Waiting until elements are present in the HTML see: 25 | https://selenium-python.readthedocs.io/waits.html 26 | """ 27 | import re 28 | import logging 29 | from typing import Any 30 | from behave import when, then # pylint: disable=no-name-in-module 31 | from selenium.webdriver.common.by import By 32 | from selenium.webdriver.support.ui import Select, WebDriverWait 33 | from selenium.webdriver.support import expected_conditions 34 | 35 | ID_PREFIX = "pet_" 36 | 37 | def save_screenshot(context: Any, filename: str) -> None: 38 | """Takes a snapshot of the web page for debugging and validation 39 | 40 | Args: 41 | context (Any): The session context 42 | filename (str): The message that you are looking for 43 | """ 44 | # Remove all non-word characters (everything except numbers and letters) 45 | filename = re.sub(r"[^\w\s]", "", filename) 46 | # Replace all runs of whitespace with a single dash 47 | filename = re.sub(r"\s+", "-", filename) 48 | context.driver.save_screenshot(f"./captures/{filename}.png") 49 | 50 | 51 | @when('I visit the "Home Page"') 52 | def step_impl(context: Any) -> None: 53 | """Make a call to the base URL""" 54 | context.driver.get(context.base_url) 55 | # Uncomment next line to take a screenshot of the web page 56 | # save_screenshot(context, 'Home Page') 57 | 58 | 59 | @then('I should see "{message}" in the title') 60 | def step_impl(context: Any, message: str) -> None: 61 | """Check the document title for a message""" 62 | assert message in context.driver.title 63 | 64 | 65 | @then('I should not see "{text_string}"') 66 | def step_impl(context: Any, text_string: str) -> None: 67 | element = context.driver.find_element(By.TAG_NAME, "body") 68 | assert text_string not in element.text 69 | 70 | 71 | @when('I set the "{element_name}" to "{text_string}"') 72 | def step_impl(context: Any, element_name: str, text_string: str) -> None: 73 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 74 | element = context.driver.find_element(By.ID, element_id) 75 | element.clear() 76 | element.send_keys(text_string) 77 | 78 | 79 | @when('I select "{text}" in the "{element_name}" dropdown') 80 | def step_impl(context: Any, text: str, element_name: str) -> None: 81 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 82 | element = Select(context.driver.find_element(By.ID, element_id)) 83 | element.select_by_visible_text(text) 84 | 85 | 86 | @then('I should see "{text}" in the "{element_name}" dropdown') 87 | def step_impl(context: Any, text: str, element_name: str) -> None: 88 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 89 | element = Select(context.driver.find_element(By.ID, element_id)) 90 | assert element.first_selected_option.text == text 91 | 92 | 93 | @then('the "{element_name}" field should be empty') 94 | def step_impl(context: Any, element_name: str) -> None: 95 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 96 | element = context.driver.find_element(By.ID, element_id) 97 | assert element.get_attribute("value") == "" 98 | 99 | 100 | ################################################################## 101 | # These two function simulate copy and paste 102 | ################################################################## 103 | @when('I copy the "{element_name}" field') 104 | def step_impl(context: Any, element_name: str) -> None: 105 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 106 | element = WebDriverWait(context.driver, context.wait_seconds).until( 107 | expected_conditions.presence_of_element_located((By.ID, element_id)) 108 | ) 109 | context.clipboard = element.get_attribute("value") 110 | logging.info("Clipboard contains: %s", context.clipboard) 111 | 112 | 113 | @when('I paste the "{element_name}" field') 114 | def step_impl(context: Any, element_name: str) -> None: 115 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 116 | element = WebDriverWait(context.driver, context.wait_seconds).until( 117 | expected_conditions.presence_of_element_located((By.ID, element_id)) 118 | ) 119 | element.clear() 120 | element.send_keys(context.clipboard) 121 | 122 | 123 | ################################################################## 124 | # This code works because of the following naming convention: 125 | # The buttons have an id in the html hat is the button text 126 | # in lowercase followed by '-btn' so the Clear button has an id of 127 | # id='clear-btn'. That allows us to lowercase the name and add '-btn' 128 | # to get the element id of any button 129 | ################################################################## 130 | 131 | 132 | @when('I press the "{button}" button') 133 | def step_impl(context: Any, button: str) -> None: 134 | button_id = button.lower().replace(" ", "_") + "-btn" 135 | context.driver.find_element(By.ID, button_id).click() 136 | 137 | 138 | @then('I should see "{name}" in the results') 139 | def step_impl(context: Any, name: str) -> None: 140 | found = WebDriverWait(context.driver, context.wait_seconds).until( 141 | expected_conditions.text_to_be_present_in_element( 142 | (By.ID, "search_results"), name 143 | ) 144 | ) 145 | assert found 146 | 147 | 148 | @then('I should not see "{name}" in the results') 149 | def step_impl(context: Any, name: str) -> None: 150 | element = context.driver.find_element(By.ID, "search_results") 151 | assert name not in element.text 152 | 153 | 154 | @then('I should see the message "{message}"') 155 | def step_impl(context: Any, message: str) -> None: 156 | # Uncomment next line to take a screenshot of the web page for debugging 157 | # save_screenshot(context, message) 158 | found = WebDriverWait(context.driver, context.wait_seconds).until( 159 | expected_conditions.text_to_be_present_in_element( 160 | (By.ID, "flash_message"), message 161 | ) 162 | ) 163 | assert found 164 | 165 | 166 | ################################################################## 167 | # This code works because of the following naming convention: 168 | # The id field for text input in the html is the element name 169 | # prefixed by ID_PREFIX so the Name field has an id='pet_name' 170 | # We can then lowercase the name and prefix with pet_ to get the id 171 | ################################################################## 172 | 173 | 174 | @then('I should see "{text_string}" in the "{element_name}" field') 175 | def step_impl(context: Any, text_string: str, element_name: str) -> None: 176 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 177 | found = WebDriverWait(context.driver, context.wait_seconds).until( 178 | expected_conditions.text_to_be_present_in_element_value( 179 | (By.ID, element_id), text_string 180 | ) 181 | ) 182 | assert found 183 | 184 | 185 | @when('I change "{element_name}" to "{text_string}"') 186 | def step_impl(context: Any, element_name: str, text_string: str) -> None: 187 | element_id = ID_PREFIX + element_name.lower().replace(" ", "_") 188 | element = WebDriverWait(context.driver, context.wait_seconds).until( 189 | expected_conditions.presence_of_element_located((By.ID, element_id)) 190 | ) 191 | element.clear() 192 | element.send_keys(text_string) 193 | -------------------------------------------------------------------------------- /k3d-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: k3d.io/v1alpha3 2 | kind: Simple 3 | name: nyu-devops 4 | servers: 1 5 | agents: 1 6 | ports: 7 | - port: 8080:80 8 | nodeFilters: 9 | - loadbalancer 10 | registries: 11 | create: 12 | name: cluster-registry 13 | host: "0.0.0.0" 14 | hostPort: "5000" 15 | config: | 16 | mirrors: 17 | "cluster-registry": 18 | endpoint: 19 | - http://cluster-registry:5000 20 | -------------------------------------------------------------------------------- /k8s/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: petshop 5 | labels: 6 | app: petshop 7 | spec: 8 | replicas: 1 9 | strategy: 10 | type: RollingUpdate 11 | rollingUpdate: 12 | maxSurge: 0% 13 | maxUnavailable: 50% 14 | selector: 15 | matchLabels: 16 | app: petshop 17 | template: 18 | metadata: 19 | labels: 20 | app: petshop 21 | spec: 22 | restartPolicy: Always 23 | containers: 24 | - name: petshop 25 | image: cluster-registry:5000/nyu-devops/petshop:1.0.0 26 | # image: petshop 27 | imagePullPolicy: IfNotPresent 28 | ports: 29 | - containerPort: 8080 30 | protocol: TCP 31 | env: 32 | - name: RETRY_COUNT 33 | value: "10" 34 | - name: DATABASE_URI 35 | valueFrom: 36 | secretKeyRef: 37 | name: postgres-creds 38 | key: database_uri 39 | readinessProbe: 40 | initialDelaySeconds: 5 41 | periodSeconds: 30 42 | httpGet: 43 | path: /health 44 | port: 8080 45 | resources: 46 | limits: 47 | cpu: "0.50" 48 | memory: "128Mi" 49 | requests: 50 | cpu: "0.25" 51 | memory: "64Mi" 52 | -------------------------------------------------------------------------------- /k8s/ingress.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: petshop 6 | annotations: 7 | nginx.ingress.kubernetes.io/rewrite-target: / 8 | spec: 9 | rules: 10 | - http: 11 | paths: 12 | - path: / 13 | pathType: Prefix 14 | backend: 15 | service: 16 | name: petshop 17 | port: 18 | number: 8080 19 | -------------------------------------------------------------------------------- /k8s/postgres/pv.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolume 3 | metadata: 4 | name: pv0001 5 | spec: 6 | capacity: 7 | storage: 1Gi 8 | volumeMode: Filesystem 9 | accessModes: 10 | - ReadWriteOnce 11 | persistentVolumeReclaimPolicy: Recycle 12 | hostPath: 13 | path: /data/pv0001 14 | -------------------------------------------------------------------------------- /k8s/postgres/pvc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: PersistentVolumeClaim 4 | metadata: 5 | name: postgres-pvc 6 | spec: 7 | accessModes: 8 | - ReadWriteOnce 9 | resources: 10 | requests: 11 | storage: 1Gi 12 | -------------------------------------------------------------------------------- /k8s/postgres/secret.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # This secret can also be created from the command line using environment variables 3 | # 4 | # export DATABASE_URI='postgresql+psycopg://:@:/' 5 | # export POSTGRES_PASSWORD='' 6 | # 7 | # kubectl create secret generic postgres-creds \ 8 | # --from-literal=password=$POSTGRES_PASSWORD 9 | # --from-literal=database_uri=$DATABASE_URI 10 | # 11 | apiVersion: v1 12 | kind: Secret 13 | metadata: 14 | name: postgres-creds 15 | data: 16 | password: cG9zdGdyZXM= 17 | database_uri: cG9zdGdyZXNxbCtwc3ljb3BnOi8vcG9zdGdyZXM6cG9zdGdyZXNAcG9zdGdyZXM6NTQzMi9wb3N0Z3Jlcw== 18 | -------------------------------------------------------------------------------- /k8s/postgres/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: postgres 6 | labels: 7 | app: postgres 8 | spec: 9 | type: ClusterIP 10 | selector: 11 | app: postgres 12 | ports: 13 | - port: 5432 14 | targetPort: 5432 15 | -------------------------------------------------------------------------------- /k8s/postgres/statefulset.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: StatefulSet 4 | metadata: 5 | name: postgres 6 | labels: 7 | app: postgres 8 | spec: 9 | serviceName: "postgres" 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: postgres 14 | template: 15 | metadata: 16 | labels: 17 | app: postgres 18 | spec: 19 | containers: 20 | - name: postgres 21 | image: postgres:15-alpine 22 | ports: 23 | - containerPort: 5432 24 | protocol: TCP 25 | env: 26 | - name: POSTGRES_PASSWORD 27 | valueFrom: 28 | secretKeyRef: 29 | name: postgres-creds 30 | key: password 31 | volumeMounts: 32 | - name: postgres-storage 33 | mountPath: /var/lib/postgresql/data 34 | resources: 35 | limits: 36 | cpu: "0.50" 37 | memory: "128Mi" 38 | requests: 39 | cpu: "0.25" 40 | memory: "64Mi" 41 | volumes: 42 | - name: postgres-storage 43 | persistentVolumeClaim: 44 | claimName: postgres-pvc 45 | # emptyDir: {} 46 | -------------------------------------------------------------------------------- /k8s/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: petshop 5 | spec: 6 | selector: 7 | app: petshop 8 | type: ClusterIP 9 | internalTrafficPolicy: Local 10 | ports: 11 | - name: http 12 | protocol: TCP 13 | port: 8080 14 | targetPort: 8080 15 | -------------------------------------------------------------------------------- /service/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2021 John J. Rofrano. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | Package: service 17 | Package for the application models and service routes 18 | This module creates and configures the Flask app and sets up the logging 19 | and SQL database 20 | """ 21 | import sys 22 | from flask import Flask 23 | from service import config 24 | from service.common import log_handlers 25 | 26 | 27 | ############################################################ 28 | # Initialize the Flask instance 29 | ############################################################ 30 | def create_app(): 31 | """Initialize the core application.""" 32 | # Create Flask application 33 | app = Flask(__name__) 34 | app.config.from_object(config) 35 | 36 | # Initialize Plugins 37 | # pylint: disable=import-outside-toplevel 38 | from service.models import db 39 | db.init_app(app) 40 | 41 | with app.app_context(): 42 | # Dependencies require we import the routes AFTER the Flask app is created 43 | # pylint: disable=wrong-import-position, wrong-import-order, unused-import 44 | from service import routes, models # noqa: F401 E402 45 | from service.common import error_handlers, cli_commands # noqa: F401, E402 46 | 47 | try: 48 | # models.init_db(app) # make our sqlalchemy tables 49 | db.create_all() 50 | except Exception as error: # pylint: disable=broad-except 51 | app.logger.critical("%s: Cannot continue", error) 52 | # gunicorn requires exit code 4 to stop spawning workers when they die 53 | sys.exit(4) 54 | 55 | # Set up logging for production 56 | log_handlers.init_logging(app, "gunicorn.error") 57 | 58 | app.logger.info(70 * "*") 59 | app.logger.info(" P E T S T O R E S E R V I C E ".center(70, "*")) 60 | app.logger.info(70 * "*") 61 | 62 | app.logger.info("Service initialized!") 63 | 64 | return app 65 | -------------------------------------------------------------------------------- /service/common/__init__.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # Copyright 2016, 2021 John J. Rofrano. All Rights Reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | ###################################################################### 16 | 17 | """ 18 | Utility package 19 | 20 | This package contains utility code that is not part 21 | of any particular application 22 | """ 23 | from .log_handlers import init_logging 24 | 25 | __all__ = ('init_logging',) 26 | -------------------------------------------------------------------------------- /service/common/cli_commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask CLI Command Extensions 3 | """ 4 | from flask import current_app as app # Import Flask application 5 | from service.models import db 6 | 7 | 8 | ###################################################################### 9 | # Command to force tables to be rebuilt 10 | # Usage: 11 | # flask db-create 12 | ###################################################################### 13 | @app.cli.command("db-create") 14 | def db_create(): 15 | """ 16 | Recreates a local database. You probably should not use this on 17 | production. ;-) 18 | """ 19 | db.drop_all() 20 | db.create_all() 21 | db.session.commit() 22 | -------------------------------------------------------------------------------- /service/common/error_handlers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2021 John J. Rofrano. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | """ 15 | Module: error_handlers 16 | """ 17 | from flask import jsonify 18 | from flask import current_app as app # Import Flask application 19 | from service.models import DataValidationError 20 | from . import status 21 | 22 | 23 | ###################################################################### 24 | # Error Handlers 25 | ###################################################################### 26 | @app.errorhandler(DataValidationError) 27 | def request_validation_error(error): 28 | """Handles Value Errors from bad data""" 29 | return bad_request(error) 30 | 31 | 32 | @app.errorhandler(status.HTTP_400_BAD_REQUEST) 33 | def bad_request(error): 34 | """Handles bad requests with 400_BAD_REQUEST""" 35 | message = str(error) 36 | app.logger.warning(message) 37 | return ( 38 | jsonify( 39 | status=status.HTTP_400_BAD_REQUEST, error="Bad Request", message=message 40 | ), 41 | status.HTTP_400_BAD_REQUEST, 42 | ) 43 | 44 | 45 | @app.errorhandler(status.HTTP_404_NOT_FOUND) 46 | def not_found(error): 47 | """Handles resources not found with 404_NOT_FOUND""" 48 | message = str(error) 49 | app.logger.warning(message) 50 | return ( 51 | jsonify(status=status.HTTP_404_NOT_FOUND, error="Not Found", message=message), 52 | status.HTTP_404_NOT_FOUND, 53 | ) 54 | 55 | 56 | @app.errorhandler(status.HTTP_405_METHOD_NOT_ALLOWED) 57 | def method_not_supported(error): 58 | """Handles unsupported HTTP methods with 405_METHOD_NOT_SUPPORTED""" 59 | message = str(error) 60 | app.logger.warning(message) 61 | return ( 62 | jsonify( 63 | status=status.HTTP_405_METHOD_NOT_ALLOWED, 64 | error="Method not Allowed", 65 | message=message, 66 | ), 67 | status.HTTP_405_METHOD_NOT_ALLOWED, 68 | ) 69 | 70 | 71 | @app.errorhandler(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) 72 | def mediatype_not_supported(error): 73 | """Handles unsupported media requests with 415_UNSUPPORTED_MEDIA_TYPE""" 74 | message = str(error) 75 | app.logger.warning(message) 76 | return ( 77 | jsonify( 78 | status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, 79 | error="Unsupported media type", 80 | message=message, 81 | ), 82 | status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, 83 | ) 84 | 85 | 86 | @app.errorhandler(status.HTTP_500_INTERNAL_SERVER_ERROR) 87 | def internal_server_error(error): 88 | """Handles unexpected server error with 500_SERVER_ERROR""" 89 | message = str(error) 90 | app.logger.error(message) 91 | return ( 92 | jsonify( 93 | status=status.HTTP_500_INTERNAL_SERVER_ERROR, 94 | error="Internal Server Error", 95 | message=message, 96 | ), 97 | status.HTTP_500_INTERNAL_SERVER_ERROR, 98 | ) 99 | -------------------------------------------------------------------------------- /service/common/log_handlers.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # Copyright 2016, 2022 John J. Rofrano. All Rights Reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | ###################################################################### 16 | 17 | """ 18 | Log Handlers 19 | 20 | This module contains utility functions to set up logging 21 | consistently 22 | """ 23 | import logging 24 | 25 | 26 | def init_logging(app, logger_name: str): 27 | """Set up logging for production""" 28 | app.logger.propagate = False 29 | gunicorn_logger = logging.getLogger(logger_name) 30 | app.logger.handlers = gunicorn_logger.handlers 31 | app.logger.setLevel(gunicorn_logger.level) 32 | # Make all log formats consistent 33 | formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] [%(module)s] %(message)s", "%Y-%m-%d %H:%M:%S %z") 34 | for handler in app.logger.handlers: 35 | handler.setFormatter(formatter) 36 | app.logger.info("Logging handler established") 37 | -------------------------------------------------------------------------------- /service/common/status.py: -------------------------------------------------------------------------------- 1 | # coding: utf8 2 | # Copyright 2016, 2021 John J. Rofrano. All Rights Reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | """ 16 | Descriptive HTTP status codes, for code readability. 17 | See RFC 2616 and RFC 6585. 18 | RFC 2616: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html 19 | RFC 6585: http://tools.ietf.org/html/rfc6585 20 | """ 21 | 22 | # Informational - 1xx 23 | HTTP_100_CONTINUE = 100 24 | HTTP_101_SWITCHING_PROTOCOLS = 101 25 | 26 | # Successful - 2xx 27 | HTTP_200_OK = 200 28 | HTTP_201_CREATED = 201 29 | HTTP_202_ACCEPTED = 202 30 | HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 31 | HTTP_204_NO_CONTENT = 204 32 | HTTP_205_RESET_CONTENT = 205 33 | HTTP_206_PARTIAL_CONTENT = 206 34 | 35 | # Redirection - 3xx 36 | HTTP_300_MULTIPLE_CHOICES = 300 37 | HTTP_301_MOVED_PERMANENTLY = 301 38 | HTTP_302_FOUND = 302 39 | HTTP_303_SEE_OTHER = 303 40 | HTTP_304_NOT_MODIFIED = 304 41 | HTTP_305_USE_PROXY = 305 42 | HTTP_306_RESERVED = 306 43 | HTTP_307_TEMPORARY_REDIRECT = 307 44 | 45 | # Client Error - 4xx 46 | HTTP_400_BAD_REQUEST = 400 47 | HTTP_401_UNAUTHORIZED = 401 48 | HTTP_402_PAYMENT_REQUIRED = 402 49 | HTTP_403_FORBIDDEN = 403 50 | HTTP_404_NOT_FOUND = 404 51 | HTTP_405_METHOD_NOT_ALLOWED = 405 52 | HTTP_406_NOT_ACCEPTABLE = 406 53 | HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 54 | HTTP_408_REQUEST_TIMEOUT = 408 55 | HTTP_409_CONFLICT = 409 56 | HTTP_410_GONE = 410 57 | HTTP_411_LENGTH_REQUIRED = 411 58 | HTTP_412_PRECONDITION_FAILED = 412 59 | HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 60 | HTTP_414_REQUEST_URI_TOO_LONG = 414 61 | HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 62 | HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 63 | HTTP_417_EXPECTATION_FAILED = 417 64 | HTTP_428_PRECONDITION_REQUIRED = 428 65 | HTTP_429_TOO_MANY_REQUESTS = 429 66 | HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 67 | 68 | # Server Error - 5xx 69 | HTTP_500_INTERNAL_SERVER_ERROR = 500 70 | HTTP_501_NOT_IMPLEMENTED = 501 71 | HTTP_502_BAD_GATEWAY = 502 72 | HTTP_503_SERVICE_UNAVAILABLE = 503 73 | HTTP_504_GATEWAY_TIMEOUT = 504 74 | HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 75 | HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 76 | -------------------------------------------------------------------------------- /service/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global Configuration for Application 3 | """ 4 | 5 | import os 6 | import logging 7 | 8 | # Get configuration from environment 9 | DATABASE_URI = os.getenv( 10 | "DATABASE_URI", "postgresql+psycopg://postgres:pgs3cr3t@localhost:5432/petstore" 11 | ) 12 | 13 | # Configure SQLAlchemy 14 | SQLALCHEMY_DATABASE_URI = DATABASE_URI 15 | SQLALCHEMY_TRACK_MODIFICATIONS = False 16 | # SQLALCHEMY_POOL_SIZE = 2 17 | 18 | # Secret for session management 19 | SECRET_KEY = os.getenv("SECRET_KEY", "sup3r-s3cr3t") 20 | LOGGING_LEVEL = logging.INFO 21 | -------------------------------------------------------------------------------- /service/models.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016, 2024 John Rofrano. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the 'License'); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an 'AS IS' BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | Models for Pet Demo Service 17 | 18 | All of the models are stored in this module 19 | 20 | Models 21 | ------ 22 | Pet - A Pet used in the Pet Store 23 | 24 | Attributes: 25 | ----------- 26 | name (string) - the name of the pet 27 | category (string) - the category the pet belongs to (i.e., dog, cat) 28 | available (boolean) - True for pets that are available for adoption 29 | gender (enum) - the gender of the pet 30 | birthday (date) - the day the pet was born 31 | 32 | """ 33 | import logging 34 | from datetime import date 35 | from enum import Enum 36 | from flask_sqlalchemy import SQLAlchemy 37 | 38 | logger = logging.getLogger("flask.app") 39 | 40 | # Create the SQLAlchemy object to be initialized later in init_db() 41 | db = SQLAlchemy() 42 | 43 | 44 | class DataValidationError(Exception): 45 | """Used for an data validation errors when deserializing""" 46 | 47 | 48 | class Gender(Enum): 49 | """Enumeration of valid Pet Genders""" 50 | 51 | MALE = 0 52 | FEMALE = 1 53 | UNKNOWN = 3 54 | 55 | 56 | class Pet(db.Model): 57 | """ 58 | Class that represents a Pet 59 | 60 | This version uses a relational database for persistence which is hidden 61 | from us by SQLAlchemy's object relational mappings (ORM) 62 | """ 63 | 64 | ################################################## 65 | # Table Schema 66 | ################################################## 67 | id = db.Column(db.Integer, primary_key=True) 68 | name = db.Column(db.String(63), nullable=False) 69 | category = db.Column(db.String(63), nullable=False) 70 | available = db.Column(db.Boolean(), nullable=False, default=False) 71 | gender = db.Column( 72 | db.Enum(Gender), nullable=False, server_default=(Gender.UNKNOWN.name) 73 | ) 74 | birthday = db.Column(db.Date(), nullable=False, default=date.today()) 75 | # Database auditing fields 76 | created_at = db.Column(db.DateTime, default=db.func.now(), nullable=False) 77 | last_updated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now(), nullable=False) 78 | 79 | ################################################## 80 | # INSTANCE METHODS 81 | ################################################## 82 | 83 | def __repr__(self): 84 | return f"" 85 | 86 | def create(self) -> None: 87 | """ 88 | Saves a Pet to the database 89 | """ 90 | logger.info("Creating %s", self.name) 91 | # id must be none to generate next primary key 92 | self.id = None # pylint: disable=invalid-name 93 | try: 94 | db.session.add(self) 95 | db.session.commit() 96 | except Exception as e: 97 | db.session.rollback() 98 | logger.error("Error creating record: %s", self) 99 | raise DataValidationError(e) from e 100 | 101 | def update(self) -> None: 102 | """ 103 | Updates a Pet to the database 104 | """ 105 | logger.info("Saving %s", self.name) 106 | if not self.id: 107 | raise DataValidationError("Update called with empty ID field") 108 | try: 109 | db.session.commit() 110 | except Exception as e: 111 | db.session.rollback() 112 | logger.error("Error updating record: %s", self) 113 | raise DataValidationError(e) from e 114 | 115 | def delete(self) -> None: 116 | """ 117 | Removes a Pet from the database 118 | """ 119 | logger.info("Deleting %s", self.name) 120 | try: 121 | db.session.delete(self) 122 | db.session.commit() 123 | except Exception as e: 124 | db.session.rollback() 125 | logger.error("Error deleting record: %s", self) 126 | raise DataValidationError(e) from e 127 | 128 | def serialize(self) -> dict: 129 | """Serializes a Pet into a dictionary""" 130 | return { 131 | "id": self.id, 132 | "name": self.name, 133 | "category": self.category, 134 | "available": self.available, 135 | "gender": self.gender.name, # convert enum to string 136 | "birthday": self.birthday.isoformat() 137 | } 138 | 139 | def deserialize(self, data: dict): 140 | """ 141 | Deserializes a Pet from a dictionary 142 | Args: 143 | data (dict): A dictionary containing the Pet data 144 | """ 145 | try: 146 | self.name = data["name"] 147 | self.category = data["category"] 148 | if isinstance(data["available"], bool): 149 | self.available = data["available"] 150 | else: 151 | raise DataValidationError( 152 | "Invalid type for boolean [available]: " 153 | + str(type(data["available"])) 154 | ) 155 | self.gender = getattr(Gender, data["gender"]) # create enum from string 156 | self.birthday = date.fromisoformat(data["birthday"]) 157 | except AttributeError as error: 158 | raise DataValidationError("Invalid attribute: " + error.args[0]) from error 159 | except KeyError as error: 160 | raise DataValidationError("Invalid pet: missing " + error.args[0]) from error 161 | except TypeError as error: 162 | raise DataValidationError( 163 | "Invalid pet: body of request contained bad or no data " + str(error) 164 | ) from error 165 | return self 166 | 167 | ################################################## 168 | # CLASS METHODS 169 | ################################################## 170 | 171 | @classmethod 172 | def all(cls) -> list: 173 | """Returns all of the Pets in the database""" 174 | logger.info("Processing all Pets") 175 | return cls.query.all() 176 | 177 | @classmethod 178 | def find(cls, pet_id: int): 179 | """Finds a Pet by it's ID 180 | 181 | :param pet_id: the id of the Pet to find 182 | :type pet_id: int 183 | 184 | :return: an instance with the pet_id, or None if not found 185 | :rtype: Pet 186 | 187 | """ 188 | logger.info("Processing lookup for id %s ...", pet_id) 189 | return cls.query.session.get(cls, pet_id) 190 | 191 | @classmethod 192 | def find_by_name(cls, name: str) -> list: 193 | """Returns all Pets with the given name 194 | 195 | :param name: the name of the Pets you want to match 196 | :type name: str 197 | 198 | :return: a collection of Pets with that name 199 | :rtype: list 200 | 201 | """ 202 | logger.info("Processing name query for %s ...", name) 203 | return cls.query.filter(cls.name == name) 204 | 205 | @classmethod 206 | def find_by_category(cls, category: str) -> list: 207 | """Returns all of the Pets in a category 208 | 209 | :param category: the category of the Pets you want to match 210 | :type category: str 211 | 212 | :return: a collection of Pets in that category 213 | :rtype: list 214 | 215 | """ 216 | logger.info("Processing category query for %s ...", category) 217 | return cls.query.filter(cls.category == category) 218 | 219 | @classmethod 220 | def find_by_availability(cls, available: bool = True) -> list: 221 | """Returns all Pets by their availability 222 | 223 | :param available: True for pets that are available 224 | :type available: str 225 | 226 | :return: a collection of Pets that are available 227 | :rtype: list 228 | 229 | """ 230 | logger.info("Processing available query for %s ...", available) 231 | return cls.query.filter(cls.available == available) 232 | 233 | @classmethod 234 | def find_by_gender(cls, gender: Gender = Gender.UNKNOWN) -> list: 235 | """Returns all Pets by their Gender 236 | 237 | :param gender: values are ['MALE', 'FEMALE', 'UNKNOWN'] 238 | :type available: enum 239 | 240 | :return: a collection of Pets that are available 241 | :rtype: list 242 | 243 | """ 244 | logger.info("Processing gender query for %s ...", gender.name) 245 | return cls.query.filter(cls.gender == gender) 246 | -------------------------------------------------------------------------------- /service/routes.py: -------------------------------------------------------------------------------- 1 | ###################################################################### 2 | # Copyright 2016, 2024 John J. Rofrano. All Rights Reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # https://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | ###################################################################### 16 | 17 | # spell: ignore Rofrano jsonify restx dbname 18 | """ 19 | Pet Store Service with UI 20 | """ 21 | from flask import jsonify, request, url_for, abort 22 | from flask import current_app as app # Import Flask application 23 | from service.models import Pet, Gender 24 | from service.common import status # HTTP Status Codes 25 | 26 | 27 | ###################################################################### 28 | # GET HEALTH CHECK 29 | ###################################################################### 30 | @app.route("/health") 31 | def health_check(): 32 | """Let them know our heart is still beating""" 33 | return jsonify(status=200, message="Healthy"), status.HTTP_200_OK 34 | 35 | 36 | ###################################################################### 37 | # GET INDEX 38 | ###################################################################### 39 | @app.route("/") 40 | def index(): 41 | """Base URL for our service""" 42 | return app.send_static_file("index.html") 43 | 44 | 45 | ###################################################################### 46 | # LIST ALL PETS 47 | ###################################################################### 48 | @app.route("/pets", methods=["GET"]) 49 | def list_pets(): 50 | """Returns all of the Pets""" 51 | app.logger.info("Request to list Pets...") 52 | 53 | pets = [] 54 | 55 | # Parse any arguments from the query string 56 | category = request.args.get("category") 57 | name = request.args.get("name") 58 | available = request.args.get("available") 59 | gender = request.args.get("gender") 60 | 61 | if category: 62 | app.logger.info("Find by category: %s", category) 63 | pets = Pet.find_by_category(category) 64 | elif name: 65 | app.logger.info("Find by name: %s", name) 66 | pets = Pet.find_by_name(name) 67 | elif available: 68 | app.logger.info("Find by available: %s", available) 69 | # create bool from string 70 | available_value = available.lower() in ["true", "yes", "1"] 71 | pets = Pet.find_by_availability(available_value) 72 | elif gender: 73 | app.logger.info("Find by gender: %s", gender) 74 | # create enum from string 75 | gender_value = getattr(Gender, gender.upper()) 76 | pets = Pet.find_by_gender(gender_value) 77 | else: 78 | app.logger.info("Find all") 79 | pets = Pet.all() 80 | 81 | results = [pet.serialize() for pet in pets] 82 | app.logger.info("[%s] Pets returned", len(results)) 83 | return jsonify(results), status.HTTP_200_OK 84 | 85 | 86 | ###################################################################### 87 | # READ A PET 88 | ###################################################################### 89 | @app.route("/pets/", methods=["GET"]) 90 | def get_pets(pet_id): 91 | """ 92 | Retrieve a single Pet 93 | 94 | This endpoint will return a Pet based on it's id 95 | """ 96 | app.logger.info("Request to Retrieve a pet with id [%s]", pet_id) 97 | 98 | # Attempt to find the Pet and abort if not found 99 | pet = Pet.find(pet_id) 100 | if not pet: 101 | abort(status.HTTP_404_NOT_FOUND, f"Pet with id '{pet_id}' was not found.") 102 | 103 | app.logger.info("Returning pet: %s", pet.name) 104 | return jsonify(pet.serialize()), status.HTTP_200_OK 105 | 106 | 107 | ###################################################################### 108 | # CREATE A NEW PET 109 | ###################################################################### 110 | @app.route("/pets", methods=["POST"]) 111 | def create_pets(): 112 | """ 113 | Create a Pet 114 | This endpoint will create a Pet based the data in the body that is posted 115 | """ 116 | app.logger.info("Request to Create a Pet...") 117 | check_content_type("application/json") 118 | 119 | pet = Pet() 120 | # Get the data from the request and deserialize it 121 | data = request.get_json() 122 | app.logger.info("Processing: %s", data) 123 | pet.deserialize(data) 124 | 125 | # Save the new Pet to the database 126 | pet.create() 127 | app.logger.info("Pet with new id [%s] saved!", pet.id) 128 | 129 | # Return the location of the new Pet 130 | location_url = url_for("get_pets", pet_id=pet.id, _external=True) 131 | return jsonify(pet.serialize()), status.HTTP_201_CREATED, {"Location": location_url} 132 | 133 | 134 | ###################################################################### 135 | # UPDATE AN EXISTING PET 136 | ###################################################################### 137 | @app.route("/pets/", methods=["PUT"]) 138 | def update_pets(pet_id): 139 | """ 140 | Update a Pet 141 | 142 | This endpoint will update a Pet based the body that is posted 143 | """ 144 | app.logger.info("Request to Update a pet with id [%s]", pet_id) 145 | check_content_type("application/json") 146 | 147 | # Attempt to find the Pet and abort if not found 148 | pet = Pet.find(pet_id) 149 | if not pet: 150 | abort(status.HTTP_404_NOT_FOUND, f"Pet with id '{pet_id}' was not found.") 151 | 152 | # Update the Pet with the new data 153 | data = request.get_json() 154 | app.logger.info("Processing: %s", data) 155 | pet.deserialize(data) 156 | 157 | # Save the updates to the database 158 | pet.update() 159 | 160 | app.logger.info("Pet with ID: %d updated.", pet.id) 161 | return jsonify(pet.serialize()), status.HTTP_200_OK 162 | 163 | 164 | ###################################################################### 165 | # DELETE A PET 166 | ###################################################################### 167 | @app.route("/pets/", methods=["DELETE"]) 168 | def delete_pets(pet_id): 169 | """ 170 | Delete a Pet 171 | 172 | This endpoint will delete a Pet based the id specified in the path 173 | """ 174 | app.logger.info("Request to Delete a pet with id [%s]", pet_id) 175 | 176 | # Delete the Pet if it exists 177 | pet = Pet.find(pet_id) 178 | if pet: 179 | app.logger.info("Pet with ID: %d found.", pet.id) 180 | pet.delete() 181 | 182 | app.logger.info("Pet with ID: %d delete complete.", pet_id) 183 | return {}, status.HTTP_204_NO_CONTENT 184 | 185 | 186 | ###################################################################### 187 | # PURCHASE A PET 188 | ###################################################################### 189 | @app.route("/pets//purchase", methods=["PUT"]) 190 | def purchase_pets(pet_id): 191 | """Purchasing a Pet makes it unavailable""" 192 | app.logger.info("Request to purchase pet with id: %d", pet_id) 193 | 194 | # Attempt to find the Pet and abort if not found 195 | pet = Pet.find(pet_id) 196 | if not pet: 197 | abort(status.HTTP_404_NOT_FOUND, f"Pet with id '{pet_id}' was not found.") 198 | 199 | # you can only purchase pets that are available 200 | if not pet.available: 201 | abort( 202 | status.HTTP_409_CONFLICT, 203 | f"Pet with id '{pet_id}' is not available.", 204 | ) 205 | 206 | # At this point you would execute code to purchase the pet 207 | # For the moment, we will just set them to unavailable 208 | 209 | pet.available = False 210 | pet.update() 211 | 212 | app.logger.info("Pet with ID: %d has been purchased.", pet_id) 213 | return pet.serialize(), status.HTTP_200_OK 214 | 215 | 216 | ###################################################################### 217 | # U T I L I T Y F U N C T I O N S 218 | ###################################################################### 219 | 220 | def check_content_type(content_type) -> None: 221 | """Checks that the media type is correct""" 222 | if "Content-Type" not in request.headers: 223 | app.logger.error("No Content-Type specified.") 224 | abort( 225 | status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, 226 | f"Content-Type must be {content_type}", 227 | ) 228 | 229 | if request.headers["Content-Type"] == content_type: 230 | return 231 | 232 | app.logger.error("Invalid Content-Type: %s", request.headers["Content-Type"]) 233 | abort( 234 | status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, 235 | f"Content-Type must be {content_type}", 236 | ) 237 | -------------------------------------------------------------------------------- /service/static/images/newapp-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyu-devops/lab-flask-bdd/79d378392ac8fd48c36494bfddc5d5bcd426cbdf/service/static/images/newapp-icon.png -------------------------------------------------------------------------------- /service/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pet Demo RESTful Service 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 16 | 17 | 18 |
19 | 20 | 21 |
Status:
22 |
23 | 24 | 25 |
26 |

Create, Retrieve, Update, and Delete a Pet:

27 |
28 |
29 |
30 | 31 |
32 | 33 |
34 |
35 | 36 | 37 |
38 |
39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 |
47 | 48 |
49 |
50 | 51 | 52 |
53 | 54 |
55 | 56 |
57 |
58 | 59 | 60 |
61 | 62 |
63 | 67 |
68 |
69 | 70 | 71 |
72 | 73 |
74 | 79 |
80 |
81 | 82 | 83 |
84 | 85 |
86 | 87 |
88 |
89 | 90 | 91 | 92 |
93 |
94 | 95 | 96 | 97 | 98 |
99 |
100 |
101 |
102 |
103 | 104 | 105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
IDNameCategoryAvailableGenderBirthday
118 |
119 | 120 |
121 |

122 |

© NYU DevOps Company 2022

123 |
124 | 125 |
126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /service/static/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){return a(b.target).is(this)?b.handleObj.handler.apply(this,arguments):void 0}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.0",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a(f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.0",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?a=!1:b.find(".active").removeClass("active")),a&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active"));a&&this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target);d.hasClass("btn")||(d=d.closest(".btn")),b.call(d,"toggle"),c.preventDefault()}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus","focus"==b.type)})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.0",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c="prev"==a?-1:1,d=this.getItemIndex(b),e=(d+c)%this.$items.length;return this.$items.eq(e)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));return a>this.$items.length-1||0>a?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){return this.sliding?void 0:this.slide("next")},c.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i="next"==b?"first":"last",j=this;if(!f.length){if(!this.options.wrap)return;f=this.$element.find(".item")[i]()}if(f.hasClass("active"))return this.sliding=!1;var k=f[0],l=a.Event("slide.bs.carousel",{relatedTarget:k,direction:h});if(this.$element.trigger(l),!l.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var m=a(this.$indicators.children()[this.getItemIndex(f)]);m&&m.addClass("active")}var n=a.Event("slid.bs.carousel",{relatedTarget:k,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),j.sliding=!1,setTimeout(function(){j.$element.trigger(n)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(n)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&"show"==b&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a(this.options.trigger).filter('[href="#'+b.id+'"], [data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.0",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0,trigger:'[data-toggle="collapse"]'},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.find("> .panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":a.extend({},e.data(),{trigger:this});c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){b&&3===b.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=c(d),f={relatedTarget:this};e.hasClass("open")&&(e.trigger(b=a.Event("hide.bs.dropdown",f)),b.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger("hidden.bs.dropdown",f)))}))}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.0",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a('