Repository: tecladocode/rest-api-sections Branch: develop Commit: 0f6d9c0cc937 Files: 1594 Total size: 19.0 MB Directory structure: gitextract_l2o0gskj/ ├── .flake8 ├── .github/ │ └── workflows/ │ └── algolia-scraper.yml ├── .gitignore ├── .gitmodules ├── .python-version ├── .templates/ │ ├── lecture.md │ └── section.md ├── CONTRIBUTING.md ├── README.md ├── dependabot.yml ├── docs/ │ ├── .gitignore │ ├── README.md │ ├── algolia.config.json │ ├── babel.config.js │ ├── docs/ │ │ ├── 01_course_intro/ │ │ │ ├── 02_how_to_install_python/ │ │ │ │ └── README.md │ │ │ ├── 03_how_to_install_ide/ │ │ │ │ └── README.md │ │ │ ├── 04_what_is_rest_api/ │ │ │ │ └── README.md │ │ │ ├── _category_.json │ │ │ └── index.md │ │ ├── 02_python_refresher/ │ │ │ ├── _category_.json │ │ │ └── index.md │ │ ├── 03_first_rest_api/ │ │ │ ├── 01_project_overview/ │ │ │ │ └── README.md │ │ │ ├── 02_getting_set_up/ │ │ │ │ └── README.md │ │ │ ├── 03_first_rest_api_endpoint/ │ │ │ │ └── README.md │ │ │ ├── 04_what_is_json/ │ │ │ │ └── README.md │ │ │ ├── 05_make_request_to_rest_api/ │ │ │ │ └── README.md │ │ │ ├── 06_creating_stores/ │ │ │ │ └── README.md │ │ │ ├── 07_creating_items/ │ │ │ │ └── README.md │ │ │ ├── 08_return_data_from_rest_api/ │ │ │ │ └── README.md │ │ │ ├── 09_final_code/ │ │ │ │ ├── README.md │ │ │ │ └── end/ │ │ │ │ └── app.py │ │ │ ├── Insomnia_section3.json │ │ │ └── _category_.json │ │ ├── 04_docker_intro/ │ │ │ ├── 01_what_is_docker_container/ │ │ │ │ ├── README.md │ │ │ │ └── docker-presentation.key │ │ │ ├── 02_run_docker_container/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── Dockerfile │ │ │ │ │ └── app.py │ │ │ │ └── start/ │ │ │ │ └── app.py │ │ │ ├── 03_in_depth_docker_tutorial/ │ │ │ │ └── README.md │ │ │ ├── 04_run_with_docker_compose/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ └── docker-compose.yml │ │ │ │ └── start/ │ │ │ │ ├── Dockerfile │ │ │ │ └── app.py │ │ │ ├── 05_run_commands_in_docker_containers/ │ │ │ │ └── README.md │ │ │ ├── README.md │ │ │ └── _category_.json │ │ ├── 05_flask_smorest/ │ │ │ ├── 01_why_flask_smorest/ │ │ │ │ └── README.md │ │ │ ├── 02_data_model_improvements/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ └── requirements.txt │ │ │ │ └── start/ │ │ │ │ ├── Dockerfile │ │ │ │ └── app.py │ │ │ ├── 03_improvements_on_first_rest_api/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ └── requirements.txt │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ └── requirements.txt │ │ │ ├── 04_new_endpoints_for_api/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ └── requirements.txt │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ └── requirements.txt │ │ │ ├── 05_reload_api_docker_container/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ └── requirements.txt │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ └── requirements.txt │ │ │ ├── 06_api_with_method_views/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ └── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ └── requirements.txt │ │ │ ├── 07_marshmallow_schemas/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── requirements.txt │ │ │ │ └── resources/ │ │ │ │ ├── __init__.py │ │ │ │ ├── item.py │ │ │ │ └── store.py │ │ │ ├── 08_validation_with_marshmallow/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 09_decorating_responses/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── Insomnia_section5_Docker.json │ │ │ ├── Insomnia_section5_before_Docker.json │ │ │ └── _category_.json │ │ ├── 06_sql_storage_sqlalchemy/ │ │ │ ├── 01_project_overview_sqlalchemy/ │ │ │ │ └── README.md │ │ │ ├── 02_create_simple_sqlalchemy_model/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 03_one_to_many_relationships_sqlalchemy/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 04_configure_flask_sqlalchemy/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 05_insert_models_sqlalchemy/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 06_get_models_or_404/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 07_updating_models_sqlalchemy/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 08_retrieve_list_all_models/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 09_delete_models_sqlalchemy/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 10_delete_related_models_sqlalchemy/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ └── store.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 11_conclusion/ │ │ │ │ └── README.md │ │ │ ├── Insomnia_section6.json │ │ │ └── _category_.json │ │ ├── 07_sqlalchemy_many_to_many/ │ │ │ ├── 01_section_changes/ │ │ │ │ └── README.md │ │ │ ├── 02_one_to_many_review/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ └── store.py │ │ │ │ └── schemas.py │ │ │ ├── 03_many_to_many_relationships/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ └── schemas.py │ │ │ ├── Insomnia_section7.json │ │ │ └── _category_.json │ │ ├── 08_flask_jwt_extended/ │ │ │ ├── 01_section_changes/ │ │ │ │ └── README.md │ │ │ ├── 02_what_is_a_jwt/ │ │ │ │ └── README.md │ │ │ ├── 03_how_is_jwt_used/ │ │ │ │ ├── README.md │ │ │ │ └── how-are-jwts-used.key │ │ │ ├── 04_flask_jwt_extended_setup/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ └── test_tag.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ └── test_tag.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ └── schemas.py │ │ │ ├── 05_user_model_and_schema/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ └── test_tag.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ └── tag.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ └── test_tag.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ └── schemas.py │ │ │ ├── 06_registering_users_rest_api/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ │ └── test_user.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ └── test_tag.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ └── tag.py │ │ │ │ └── schemas.py │ │ │ ├── 07_login_users_rest_api/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ │ └── test_user.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ └── test_user.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 08_protect_resources_with_jwt_required/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ │ └── test_user.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ └── test_user.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 09_jwt_claims_and_authorization/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ │ └── test_user.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ └── test_user.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 10_logout_users_rest_api/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ │ └── test_user.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ └── test_user.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 11_insomnia_request_chaining/ │ │ │ │ └── README.md │ │ │ ├── 12_token_refreshing_flask_jwt_extended/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flake8 │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── conftest.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements-dev.txt │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ │ └── test_user.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ ├── end_video/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flake8 │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── conftest.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements-dev.txt │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── conftest.py │ │ │ │ │ │ ├── test_item.py │ │ │ │ │ │ ├── test_store.py │ │ │ │ │ │ ├── test_tag.py │ │ │ │ │ │ └── test_user.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── Insomnia_section8_before_chaining.json │ │ │ ├── Insomnia_section8_chaining.json │ │ │ └── _category_.json │ │ ├── 09_flask_migrate/ │ │ │ ├── 01_why_use_database_migrations/ │ │ │ │ └── README.md │ │ │ ├── 02_add_flask_migrate_to_app/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 03_initialize_database_flask_db_init/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .python-version │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ └── c575166f6192_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flaskenv │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 04_change_models_generate_alembic_migration/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .dockerignore │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .python-version │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── bcc005bc255c_.py │ │ │ │ │ │ └── c575166f6192_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── .flaskenv │ │ │ │ ├── .python-version │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ └── c575166f6192_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 05_manually_review_modify_migrations/ │ │ │ │ └── README.md │ │ │ └── _category_.json │ │ ├── 10_git_crash_course/ │ │ │ ├── README.md │ │ │ └── _category_.json │ │ ├── 11_deploy_to_render/ │ │ │ ├── 01_section_overview/ │ │ │ │ └── README.md │ │ │ ├── 02_create_render_web_service/ │ │ │ │ └── README.md │ │ │ ├── 03_docker_with_gunicorn/ │ │ │ │ └── README.md │ │ │ ├── 04_deploy_postgresql_database/ │ │ │ │ └── README.md │ │ │ ├── 05_environment_variables_and_migrations/ │ │ │ │ └── README.md │ │ │ ├── 06_run_everything_docker_compose/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-compose.yml │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ └── bb5da1e68550_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ └── bb5da1e68550_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── Insomnia_section11.json │ │ │ └── _category_.json │ │ ├── 12_task_queues_emails/ │ │ │ ├── 01_send_emails_python_mailgun/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ └── bb5da1e68550_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ └── bb5da1e68550_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 02_send_email_user_registration/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .python-version │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ └── schemas.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ └── bb5da1e68550_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 03_what_is_task_queue/ │ │ │ │ └── README.md │ │ │ ├── 04_populate_rq_task_queue/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── schemas.py │ │ │ │ │ └── tasks.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ └── schemas.py │ │ │ ├── 05_rq_background_worker/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── schemas.py │ │ │ │ │ └── tasks.py │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── schemas.py │ │ │ │ └── tasks.py │ │ │ ├── 06_sending_html_emails/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .python-version │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── schemas.py │ │ │ │ │ ├── tasks.py │ │ │ │ │ └── templates/ │ │ │ │ │ └── email/ │ │ │ │ │ ├── action.html │ │ │ │ │ └── action.original.html │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── schemas.py │ │ │ │ └── tasks.py │ │ │ ├── 07_deploy_background_worker_render/ │ │ │ │ ├── README.md │ │ │ │ ├── end/ │ │ │ │ │ ├── .flaskenv │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── .python-version │ │ │ │ │ ├── Dockerfile │ │ │ │ │ ├── README.md │ │ │ │ │ ├── app.py │ │ │ │ │ ├── blocklist.py │ │ │ │ │ ├── db.py │ │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ │ ├── migrations/ │ │ │ │ │ │ ├── README │ │ │ │ │ │ ├── alembic.ini │ │ │ │ │ │ ├── env.py │ │ │ │ │ │ ├── script.py.mako │ │ │ │ │ │ └── versions/ │ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── item_tags.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── requirements.txt │ │ │ │ │ ├── resources/ │ │ │ │ │ │ ├── item.py │ │ │ │ │ │ ├── store.py │ │ │ │ │ │ ├── tag.py │ │ │ │ │ │ └── user.py │ │ │ │ │ ├── schemas.py │ │ │ │ │ ├── settings.py │ │ │ │ │ ├── tasks.py │ │ │ │ │ └── templates/ │ │ │ │ │ └── email/ │ │ │ │ │ ├── action.html │ │ │ │ │ └── action.original.html │ │ │ │ └── start/ │ │ │ │ ├── .flaskenv │ │ │ │ ├── .gitignore │ │ │ │ ├── .python-version │ │ │ │ ├── Dockerfile │ │ │ │ ├── README.md │ │ │ │ ├── app.py │ │ │ │ ├── blocklist.py │ │ │ │ ├── db.py │ │ │ │ ├── docker-entrypoint.sh │ │ │ │ ├── migrations/ │ │ │ │ │ ├── README │ │ │ │ │ ├── alembic.ini │ │ │ │ │ ├── env.py │ │ │ │ │ ├── script.py.mako │ │ │ │ │ └── versions/ │ │ │ │ │ ├── 07006e31e788_.py │ │ │ │ │ ├── 8ca023a4a4b0_.py │ │ │ │ │ ├── bb5da1e68550_.py │ │ │ │ │ └── d8e0f80631fb_.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── item.py │ │ │ │ │ ├── item_tags.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── resources/ │ │ │ │ │ ├── item.py │ │ │ │ │ ├── store.py │ │ │ │ │ ├── tag.py │ │ │ │ │ └── user.py │ │ │ │ ├── schemas.py │ │ │ │ ├── tasks.py │ │ │ │ └── templates/ │ │ │ │ └── email/ │ │ │ │ ├── action.html │ │ │ │ └── action.original.html │ │ │ ├── Insomnia_section12.json │ │ │ └── _category_.json │ │ └── Insomnia_all_sections.json │ ├── docusaurus.config.js │ ├── package.json │ ├── sidebars.js │ └── src/ │ ├── components/ │ │ ├── HomepageFeatures/ │ │ │ ├── index.js │ │ │ └── styles.module.css │ │ ├── LockedVideoEmbed/ │ │ │ └── index.js │ │ └── VideoEmbed/ │ │ └── index.js │ ├── css/ │ │ └── custom.css │ └── pages/ │ ├── index.js │ ├── index.module.css │ └── insomnia-files.md └── project/ ├── 01-first-rest-api/ │ └── app.py ├── 02-first-rest-api-docker/ │ ├── Dockerfile │ └── app.py ├── 03-items-stores-smorest/ │ ├── .flaskenv │ ├── Dockerfile │ ├── app.py │ ├── db.py │ ├── requirements.txt │ ├── resources/ │ │ ├── __init__.py │ │ ├── item.py │ │ └── store.py │ └── schemas.py ├── 04-items-stores-smorest-sqlalchemy/ │ ├── .flaskenv │ ├── Dockerfile │ ├── app.py │ ├── db.py │ ├── models/ │ │ ├── __init__.py │ │ ├── item.py │ │ └── store.py │ ├── requirements.txt │ ├── resources/ │ │ ├── __init__.py │ │ ├── item.py │ │ └── store.py │ └── schemas.py ├── 05-add-many-to-many/ │ ├── .flaskenv │ ├── Dockerfile │ ├── app.py │ ├── conftest.py │ ├── db.py │ ├── models/ │ │ ├── __init__.py │ │ ├── item.py │ │ ├── item_tags.py │ │ ├── store.py │ │ └── tag.py │ ├── requirements.txt │ ├── resources/ │ │ ├── __init__.py │ │ ├── __tests__/ │ │ │ ├── conftest.py │ │ │ ├── test_item.py │ │ │ ├── test_store.py │ │ │ └── test_tag.py │ │ ├── item.py │ │ ├── store.py │ │ └── tag.py │ └── schemas.py ├── 06-add-db-migrations/ │ ├── .flaskenv │ ├── .python-version │ ├── Dockerfile │ ├── app.py │ ├── conftest.py │ ├── db.py │ ├── migrations/ │ │ ├── README │ │ ├── alembic.ini │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions/ │ │ ├── 5acd69659946_.py │ │ └── a40bdfbd7a9d_.py │ ├── models/ │ │ ├── __init__.py │ │ ├── item.py │ │ ├── item_tags.py │ │ ├── store.py │ │ └── tag.py │ ├── requirements.txt │ ├── resources/ │ │ ├── __init__.py │ │ ├── __tests__/ │ │ │ ├── conftest.py │ │ │ ├── test_item.py │ │ │ ├── test_store.py │ │ │ └── test_tag.py │ │ ├── item.py │ │ ├── store.py │ │ └── tag.py │ └── schemas.py ├── using-flask-restful/ │ ├── .flaskenv │ ├── Flask-JWT-Extended.postman_collection.json │ ├── Stores_REST_API_2022-01-14.json │ ├── app.py │ ├── blocklist.py │ ├── db.py │ ├── models/ │ │ ├── __init__.py │ │ ├── item.py │ │ ├── item_tags.py │ │ ├── store.py │ │ ├── tag.py │ │ └── user.py │ ├── requirements.txt │ └── resources/ │ ├── __init__.py │ ├── item.py │ ├── store.py │ ├── tag.py │ └── user.py ├── using-flask-restx/ │ ├── .flaskenv │ ├── Flask-JWT-Extended.postman_collection.json │ ├── Stores_REST_API_2022-01-14.json │ ├── app.py │ ├── blocklist.py │ ├── db.py │ ├── models/ │ │ ├── __init__.py │ │ ├── item.py │ │ ├── item_tags.py │ │ ├── store.py │ │ ├── tag.py │ │ └── user.py │ ├── requirements.txt │ └── resources/ │ ├── __init__.py │ ├── item.py │ ├── store.py │ ├── tag.py │ └── user.py └── using-flask-smorest/ ├── .flaskenv ├── Flask-JWT-Extended.postman_collection.json ├── Stores_REST_API_2022-01-14.json ├── app.py ├── blocklist.py ├── db.py ├── models/ │ ├── __init__.py │ ├── item.py │ ├── item_tags.py │ ├── store.py │ ├── tag.py │ └── user.py ├── requirements.txt ├── resources/ │ ├── __init__.py │ ├── item.py │ ├── store.py │ ├── tag.py │ └── user.py └── schemas.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .flake8 ================================================ [flake8] max-line-length = 88 exclude = .git,__pycache__ max-complexity = 10 ================================================ FILE: .github/workflows/algolia-scraper.yml ================================================ name: Run Algolia Scraper on: push: branches: ["master", "develop"] pull_request: branches: ["master", "develop"] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: darrenjennings/algolia-docsearch-action@master with: algolia_application_id: "1BEGBIP9SH" algolia_api_key: ${{ secrets.ALGOLIA_API_KEY }} file: "docs/algolia.config.json" ================================================ FILE: .gitignore ================================================ .vscode/ *.pyc .idea/ __pycache__/ *.db .DS_Store venv/ .venv/ docs/docs/.nota/config.ini section-start-code.zip section-end-code.zip ================================================ FILE: .gitmodules ================================================ [submodule "Flask-Smorest Docker"] path = project/using-flask-smorest-docker url = https://github.com/tecladocode/rest-api-smorest-docker ================================================ FILE: .python-version ================================================ 3.10.0 ================================================ FILE: .templates/lecture.md ================================================ --- title: The lecture title goes here description: A brief description of the lecture goes here. --- - [ ] Set metadata above - [ ] Start writing! - [ ] Create `start` folder - [ ] Create `end` folder - [ ] Write TL;DR - [ ] Create per-file diff between `end` and `start` (use "Compare Folders") # Lecture Title ================================================ FILE: .templates/section.md ================================================ --- name: "Section name here" --- # Section name here Description of the section goes here. ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute to this course ## E-book contributions ### How to run the e-book Clone the repo and navigate to the `docs` folder. There, run: ``` npm install ``` Then you can run the e-book with: ``` npm run start ``` If you make any changes to the e-book, please keep changes as simple as possible and create a PR with your changes into the `develop` branch. If you are making larger changes, please create a Discussion first and let's talk about it! ### Making changes to projects All the finished projects that we cover in the course are in the `projects` folder. Making changes to these projects is done very carefully, especially after recording. Please start a Discussion before making any changes, as doing so can make the experience for students confusing (if the videos and e-book are different). ================================================ FILE: README.md ================================================ # REST APIs with Flask and Python

REST APIs with Flask and Python (Udemy banner image)

[![Udemy rating 4.6/5](https://img.shields.io/badge/udemy-4.6%2F5-brightgreen)](https://go.tecla.do/rest-apis-ebook) ![GitHub last commit](https://img.shields.io/github/last-commit/tecladocode/rest-apis-flask-python/develop) ![Python 3.10](https://img.shields.io/badge/python-3.10-yellow) [![Discord](https://img.shields.io/discord/614395983807250433)](https://discord.gg/78Nvd3p) [![Twitter Follow](https://img.shields.io/twitter/follow/jslvtr?style=social) ](https://twitter.com/jslvtr)
---

💡 A full course to teach you how to use Flask and Python to make REST APIs using multiple Flask extensions and PostgreSQL.

## Getting started Enrol in the course by going to [this link](https://go.tecla.do/rest-apis-ebook). Then you can come back here to download the repository. This repository contains the code that we develop in each section of the course. If you are familiar with Git, you can use Git to download it. Otherwise, you can download it as a zip file: ![Download repo as a zip file](assets/download-repo-zip.png) Next, start taking the course at the beginning! You can use the downloaded code files to support you while you go through the course. I also **strongly recommend** you code while you take the course. A good strategy is: 1. Watch the video intently, optionally while taking notes. 2. Watch again, more quickly, while typing the code together with me. 3. Once you're done with the video, play about with the code. Make changes, break things, then fix them, and try to thoroughly understand everything the code does. If you do this for the entire course, I guarantee you will learn how to make REST APIs using Flask and Python well and quickly. You'll still be using Google and searching for stuff every day, but so does everyone else! ## Section 2: A Full Python Refresher This section (only available on Udemy) helps programmers who are new to Python get acquainted with the language. It is not a complete-beginner Python course! ## Section 3: Your first REST API The code in this section includes a simple Flask app that accepts and returns JSON data. ## Section 4: Docker Introduction to Docker to run your REST APIs. We talk about images, containers, and how to run applications. ## Section 5: Flask-Smorest We introduce the Flask-Smorest extension, a library that greatly simplifies writing REST APIs using Flask. It also provides things like automated documentation generation. ## Section 6: Flask-SQLAlchemy The code in this section extends the previous section by replacing the data storage in Python lists with SQLAlchemy, an ORM (Object-Relational Mapping which simplifies connecting to and interacting with a database. ## Section 7: Many-to-many relationships In this section we talk about many-to-many relationships using SQLAlchemy. ## Section 8: Authentication with Flask-JWT-Extended Learn how to perform user authentication using JWTs and the Flask-JWT-Extended library. Here we talk about access token JWTs, as well as refresh tokens, JWT claims, blocklists, password hashing, and more. ## Section 9: Flask-Migrate After deploying your apps, making changes to the database can be really tricky because you have to log in to the database server and manually update the database tables using SQL commands. Flask-Migrate and the Alembic libraries simplify this job by creating migration scripts. ## Section 10: Git Crash Course A quick and intense course on Git and GitHub for code sharing. ## Section 11: Deploying to Render.com Learn how to get your code running in the cloud and make it publicly accessible. In this section we use Render.com for deployments and we also deploy a PostgreSQL database. ================================================ FILE: dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/docs" schedule: interval: "weekly" # Check for npm updates on Sundays day: "sunday" target-branch: "develop" # Labels on pull requests for security and version updates labels: - "npm dependencies" ================================================ FILE: docs/.gitignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: docs/README.md ================================================ # Website This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. ### Installation ``` $ npm install ``` ### Local Development ``` $ npm run start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ### Build ``` $ npm run build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ### Deployment Using SSH: ``` $ USE_SSH=true npm run deploy ``` Not using SSH: ``` $ GIT_USER= npm run deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. ================================================ FILE: docs/algolia.config.json ================================================ { "index_name": "docusaurus-2", "start_urls": [ "https://rest-apis-flask.teclado.com/" ], "sitemap_urls": [ "https://rest-apis-flask.teclado.com/sitemap.xml" ], "sitemap_alternate_links": true, "stop_urls": [ "/tests" ], "selectors": { "lvl0": { "selector": "(//ul[contains(@class,'menu__list')]//a[contains(@class, 'menu__link menu__link--sublist menu__link--active')]/text() | //nav[contains(@class, 'navbar')]//a[contains(@class, 'navbar__link--active')]/text())[last()]", "type": "xpath", "global": true, "default_value": "Documentation" }, "lvl1": "header h1", "lvl2": "article h2", "lvl3": "article h3", "lvl4": "article h4", "lvl5": "article h5, article td:first-child", "lvl6": "article h6", "text": "article p, article pre, article li, article td:last-child" }, "strip_chars": " .,;:#", "custom_settings": { "separatorsToIndex": "_", "attributesForFaceting": [ "language", "version", "type", "docusaurus_tag" ], "attributesToRetrieve": [ "hierarchy", "content", "anchor", "url", "url_without_anchor", "type" ] }, "conversation_id": [ "833762294" ] } ================================================ FILE: docs/babel.config.js ================================================ module.exports = { presets: [require.resolve('@docusaurus/core/lib/babel/preset')], }; ================================================ FILE: docs/docs/01_course_intro/02_how_to_install_python/README.md ================================================ --- title: How to install Python description: A brief description of the lecture goes here. ctslug: how-to-install-python --- # How to install Python on your computer In this lecture I'll guide you through installing Python on your computer. If have already installed Python, feel free to skip to the next lecture! ## On Windows To install Python, download the latest version of Python from https://www.python.org. At the time of writing, that was Python 3.10.4. :::caution Add to PATH As you go through the installer, make sure to check "Add Python to PATH". ::: Once Python is installed, you can execute the program `cmd.exe`. This is a command-line interface to your computer. Here, just type the word `python` and that will start the Python program. At all points during the course, you can always type `python name_of_file.py` and that will execute the code of a file called `name_of_file.py` If you have multiple versions of Python installed, such as a version you installed a while ago, you'll need to use the complete path to Python in order to run it. Usually it'll look something like this: ``` C:\\Users\\yourname\\AppData\\Local\\Programs\\Python\\Python39-32\\python.exe ``` When you use an IDE, such as [Visual Studio Code](../how_to_install_ide/), you can use the integrated terminal instead of `cmd.exe`. ## On Mac To install Python, download the latest version of Python from https://www.python.org. At the time of writing, that was Python 3.10.4. Once Python is installed, you can execute the program `Terminal.app`. This is a command-line interface to your computer. Here, just type the word `python3.9` and that will start the Python program. At all points during the course, you can always type `python3.9 name_of_file.py` and that will execute the code of a file called `name_of_file.py`. ================================================ FILE: docs/docs/01_course_intro/03_how_to_install_ide/README.md ================================================ --- title: How to install an IDE description: What IDE should you use? How do you install it? Let me show you in this quick guide. ctslug: how-to-install-an-ide --- # How to install an IDE An IDE is an Integrated Development Environment. If you've got experience coding, I'm sure you've used an IDE at some point or another. IDEs are text editors that let you modify your code. However, as the name says, they do a bit more than just that. Often we can use IDEs to run our code, connect to databases, use a debugger, or a whole host of other things! Throughout this course I use Visual Studio Code. It's a very powerful IDE that you can get for free at https://code.visualstudio.com/. If you get VS Code, I've got a blog post on how to set it up for Python development: https://blog.tecladocode.com/how-to-set-up-visual-studio-code-for-python-development/ ## Opening Projects Whenever you work using an IDE, you should open separate projects in separate windows: - 👍 When you start a section of the course, make a folder for that section and open it with VSCode. Now VSCode treats that as a "project" folder. - 👎 Make a folder for the entire course and open it with VSCode. Inside it, make a folder for each section. VSCode will treat the top-level course folder as the "project", and your experience will be a bit more difficult. I've noticed some students like opening their "projects" folder with the IDE, so that they can work on all their projects in one window. This is likely to cause problems due to how Python looks for code files to use and import (more on that when you get to the "Imports" section of the Python Refresher!). So don't be afraid to have many different project folders, each one with their own virtual environment and dependencies. That's normal and will make it much easier to work with! ================================================ FILE: docs/docs/01_course_intro/04_what_is_rest_api/README.md ================================================ --- title: "What is a REST API?" description: "There's a lot of confusion around what is and isn't a REST API. Let's take a look!" ctslug: what-is-an-rest-api --- # What is a REST API? ## What is an API? API stands for "Application Programming Interface", but that's not an overly helpful name! The most important part of the term is "Interface". Just as the interface to a car is the parts we humans interact with (steering wheel, pedals, gear stick), the interface to an application is the code that another application can interact with. This way, any part of an application that can be "called" or "executed" from another application, is part of that application's API. For example, let's say you make a simple Python library to save data to a database. This is what the library looks like: ```py def save_to_db(what_to_save): pass def get_from_db(query): pass ``` Assume that the functions are implemented and they do something! This "library" has an API: the `save_to_db` and `get_from_db` functions. These are the functions that the library makes available to other programs (or parts of programs), which those other programs should use to save and get data from a database. If you look at it this way, almost everything in programming has at least an "interface". As another example, when you code a class, it has an interface: the public attributes and methods. So the key to an API is that it has to be publicly callable, and it allows the _client_ (whoever calls it) to interact with the program that offers the API. ### An API with Flask When we make Flask apps, we also have some public functions that can be called. Our public functions are each associated to an endpoint, such as `/store`. That way a client (such as another Python program, or even a web browser) can access the `/store` endpoint of our application, and we can run some code and return a value. If our Flask app is hosted at `http://my-flask-app.com`, then accessing `http://my-flask-app.com/store` would execute the function associated with the `/store` endpoint in our app, and the client would receive the data returned by the associated function. That data might look like this: ```json { "stores": [ { "name": "My Store", "items": [ { "name": "my item", "price": 15.99 } ] } ] } ``` ### The purpose of APIs We've learned that we can make a Flask app and expose certain functions to the public by using endpoints. Clients can then make requests (we'll learn how later), and get data. Clients can also send data, which the Flask functions can use. But _why_? If you want to use certain functions, why not just code them in your application? There's one main reason: so two or more clients can use the API without having to duplicate the logic that the API offers inside their own code. Let's say you want to build a weather app. You could try to install sensors at the top of your house, connect them directly to the computer running your code, and then offer weather info based on what the sensors say... Or you could request weather data from the OpenWeatherMap API, just as tens of thousands of other devices do. Much easier, and all you have to do is make a request to the API! ### What is a client? An API client can be any device, such as a web app or a mobile app. ### Making an API for your own consumption Many software companies make APIs that only they use (so they aren't fully public). Here's an example. You're making a multiplayer mobile game, and you need to store information about the moves that your character is making. In your mobile app code, you could connect to a central database and store the moves there. Apps in other mobile devices would also connect to the central database and store (and read) the moves from there. But what happens when you want to expand your app to other devices? Let's say, iOS and Android? Then you've got to duplicate your database logic in two places: the two app codebases. The problem is compounded if you want to expand to computers, consoles, etc. It's easier to have an API which exposes certain functions that let your app save and retrieve data from a database, and have all your devices use that same API. It will be much simpler, and when you want to make database changes you most likely won't have to change the code of each mobile app. ## What is REST? Now that you know what an API is, a slightly more difficult question to answer is "What is a REST API?". A REST API is just an API that follows specific conventions and has specific characteristics. REST APIs deal in resources, so every individual "thing" that can be named is a resource. For example, stores, items, tags, users, or less concrete things like temporal services or collections of other resources. The main characteristics (or constraints) of a REST API are: 1. **Uniform interface**. Whichever way clients should access a certain resource should also be the way the access other resources. Clients should have a single way to retrieve resources. 2. **Client-server**. Clients should know the endpoints of the API, but they should not be coupled to the development of the API. A client or a server may be swapped out for a different implementation without the other noticing. 3. **Stateless**. The server (API) doesn't store anything about previous client requests. Each client request is treated as a brand new client. If the client needs the server to personalize the response, then the client must send the server whatever information the server needs in order to do so. 4. **Cacheable**. The client or server must be able to cache the resources returned by the API. This is a very general constraint, but it's an important one. 5. **Layered system**. REST APIs may be developed as multiple layers, where each layer interacts [only with the layer above and below it](https://excalidraw.com/#json=or3Umoigss4yIeuKg3cO8,qH6uDDCXc7DSjweqNvlmzw). If you'd like to read a very complete and exhaustive guide about everything that a REST API is, check out [this guide](https://restfulapi.net/). ## The API we'll build in this course In this course we'll build a REST API to expose interactions with stores, items, tags, and users. The API will allow clients to: - Create and retrieve information about stores. - Create, retrieve, search for, update, and delete items in those stores. - Create tags and link them to items; and search for items with specific tags. - Add user authentication to the client apps using the API. The API will have the endpoints shown below. :::info What do the locks mean? It's usually important in APIs that only certain people have access to certain endpoints. For example, paying customers may have access to certain endpoints while free users may not. We'll deal with user authentication in a later section, but that's what the locks (🔒) mean below. - One 🔒 means the user will need to have authenticated within the last few days to make a request. - Two 🔒🔒 means the user will need to have authenticated within the last few minutes to make a request. - No locks means anybody can make a request. ::: ### Users | Method | Endpoint | Description | | -------------- | ----------------- | ----------------------------------------------------- | | `POST` | `/register` | Create user accounts given an `email` and `password`. | | `POST` | `/login` | Get a JWT given an `email` and `password`. | | 🔒
`POST` | `/logout` | Revoke a JWT. | | 🔒
`POST` | `/refresh` | Get a fresh JWT given a refresh JWT. | | `GET` | `/user/{user_id}` | (dev-only) Get info about a user given their ID. | | `DELETE` | `/user/{user_id}` | (dev-only) Delete a user given their ID. | ### Stores | Method | Endpoint | Description | | -------- | ------------- | ---------------------------------------- | | `GET` | `/store` | Get a list of all stores. | | `POST` | `/store` | Create a store. | | `GET` | `/store/{id}` | Get a single store, given its unique id. | | `DELETE` | `/store/{id}` | Delete a store, given its unique id. | ### Items | Method | Endpoint | Description | | ---------------- | ------------ | --------------------------------------------------------------------------------------------------- | | 🔒
`GET` | `/item` | Get a list of all items in all stores. | | 🔒🔒
`POST` | `/item` | Create a new item, given its name and price in the body of the request. | | 🔒
`GET` | `/item/{id}` | Get information about a specific item, given its unique id. | | `PUT` | `/item/{id}` | Update an item given its unique id. The item name or price can be given in the body of the request. | | 🔒
`DELETE` | `/item/{id}` | Delete an item given its unique id. | ### Tags | Method | Endpoint | Description | | -------- | --------------------- | ------------------------------------------------------- | | `GET` | `/store/{id}/tag` | Get a list of tags in a store. | | `POST` | `/store/{id}/tag` | Create a new tag. | | `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | | `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | | `GET` | `/tag/{id}` | Get information about a tag given its unique id. | | `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | As you can see, we've got a lot to build! We'll start building REST APIs in section 3, "Your first REST API". Here we'll create a simpler version of the REST API detailed above, without tags or user authentication. Then, over the following sections, we'll improve on this REST API. We'll add: - Flask extensions to simplify our code. - Use Docker to run the API more reliably. - Use PostgreSQL for data storage. - Add user authentication. - Add item tagging. - Add an admin panel so changing data manually is a bit easier. - And much more! ================================================ FILE: docs/docs/01_course_intro/_category_.json ================================================ { "label": "Course Introduction", "position": 1 } ================================================ FILE: docs/docs/01_course_intro/index.md ================================================ --- id: intro --- # REST APIs with Flask and Python import VideoEmbed from "@site/src/components/VideoEmbed";
Hi, and welcome! REST APIs with Flask and Python is a complete course that teaches you how to develop complete, professional REST APIs using **Flask**, **PostgreSQL**, and **Docker**. In this website you can find the complete course notes and code. We made this to help students navigate the course more easily. Also, every single piece of code we write in the course is in the notes, with brief explanations. We've found that really helps review the course content later on! This is how we recommend taking the course: - Start at the first section of the course, and watch the videos. If you're comfortable, watch them in 1.25x or 1.5x speed! This will help you understand the content holistically. - Then, re-watch the videos slowly while coding together with me. Write every piece of code by hand, just as you see it in the videos. By the time you're done with a section, after writing all the code, you'll be very comfortable with all the content in it. - Move onto the next section, but keep the code from each section in a different folder. That way you can then look at the full project as it was at the end of each section, and that will help you review! I promise that if you follow this approach, you will master the content of this course within a few weeks. And you'll soon be able to code complete REST APIs using Flask and Python! :::tip Note-taking not required Feel free to take notes while you go through the course, but you don't need to! This very website is a perfect set of notes for you to come back to weeks, months, or even years down the line to review what you learned in the course (and, let's be honest, find those snippets of code that you can just copy into your projects). ::: ## Course Set-up and Housekeeping Below are some quick guides to get you started. Feel free to read through them if you need, or skip them! After this, we have a [Python Refresher](../02_python_refresher/index.md). If you are very comfortable with Python, feel free to skip that too! If you're skipping the Python Refresher, move onto Your First REST API. I'll see you there! import DocCardList from '@theme/DocCardList'; import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; ================================================ FILE: docs/docs/02_python_refresher/_category_.json ================================================ { "label": "Python Refresher", "position": 2 } ================================================ FILE: docs/docs/02_python_refresher/index.md ================================================ --- name: "A Complete Python Refresher" --- # Python Refresher We've included a Python Refresher section in this course to help you, in case you haven't done any Python for a while or you are coming from a different programming language. There are no written notes for this section, but all the videos are available in the Udemy course. If you are fully new to programming _and_ Python, this course may be a bit advanced. Feel free to read through out beginner Python e-book anyway, as it might be helpful: [https://python.tecladocode.com](https://python.tecladocode.com). ================================================ FILE: docs/docs/03_first_rest_api/01_project_overview/README.md ================================================ --- title: Project Overview description: A first look at the project we'll build in this section. ctslug: overview-of-your-first-rest-api --- # Overview of your first REST API import VideoEmbed from "@site/src/components/VideoEmbed";
In this section we'll make a simple REST API that allows us to: - Create stores, each with a `name` and a list of stocked `items`. - Create an item within a store, each with a `name` and a `price`. - Retrieve a list of all stores and their items. - Given its `name`, retrieve an individual store and all its items. - Given a store `name`, retrieve only a list of item within it. This is how the interaction will go! :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! ::: ## Create stores Request: ``` POST /store {"name": "My Store"} ``` Response: ``` {"name": "My Store", "items": []} ``` ## Create items Request: ``` POST /store/My Store/item {"name": "Chair", "price": 175.50} ``` Response: ``` {"name": "Chair", "price": 175.50} ``` ## Retrieve all stores and their items Request: ``` GET /store ``` Response: ``` { "stores": [ { "name": "My Store", "items": [ { "name": "Chair", "price": 175.50 } ] } ] } ``` ## Get a particular store Request: ``` GET /store/My Store ``` Response: ``` { "name": "My Store", "items": [ { "name": "Chair", "price": 175.50 } ] } ``` ## Get only items in a store Request: ``` GET /store/My Store/item ``` Response: ``` [ { "name": "Chair", "price": 175.50 } ] ``` ================================================ FILE: docs/docs/03_first_rest_api/02_getting_set_up/README.md ================================================ --- title: Getting set up description: Set up a Flask project and create the Flask app. ctslug: getting-set-up --- # Getting set up import VideoEmbed from "@site/src/components/VideoEmbed";
Create a virtual environment and activate it. ``` python3.10 -m venv .venv source .venv/bin/activate ``` Install Flask. ``` pip install flask ``` Create a file for the Flask app (I like to call it `app.py`) Create the Flask app. ```py title="app.py" from flask import Flask app = Flask(__name__) ``` Now you can run this app using the Flask Command-Line Interface (CLI): ``` flask run ``` But the app doesn't do anything yet! Let's work on our first API endpoint next. ================================================ FILE: docs/docs/03_first_rest_api/03_first_rest_api_endpoint/README.md ================================================ --- title: Your First REST API Endpoint description: Learn how to define a REST API endpoint using Flask. ctslug: your-first-rest-api-endpoint --- # Your First REST API Endpoint import LockedVideoEmbed from "@site/src/components/LockedVideoEmbed"; Let's start off by defining where we'll store our data. In most REST APIs, you'd store your data in a database. For now, and for simplicity, we'll store it in a Python list. Later on we'll work on making this data dynamic. For now let's use some sample data. ```py title="app.py" from flask import Flask app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] ``` Now that we've got the data stored, we can go ahead and make a Flask route that, when accessed, will return all our data. ```py title="app.py" from flask import Flask app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} ``` ## Anatomy of a Flask route There are two parts to a Flask route: - The endpoint decorator - The function that should run The endpoint decorator (`@app.get("/store")`) _registers_ the route's endpoint with Flask. That's the `/store` bit. That way, the Flask app knows that when it receives a request for `/store`, it should run the function. The function's job is to do everything that it should, and at the end return _something_. In most REST APIs, we return JSON, but you can return anything that can be represented as text (e.g. XML, HTML, YAML, plain text, or almost anything else). ================================================ FILE: docs/docs/03_first_rest_api/04_what_is_json/README.md ================================================ --- title: "What is JSON?" description: JSON is the way we normally transfer data to and from REST APIs. ctslug: what-is-json --- # What is JSON? JSON is just a (usually long) string whose contents follow a specific format. One example of JSON: ```json { "key": "value", "another": 25, "listic_data": [ 1, 3, 7 ], "sub_objects": { "name": "Rolf", "age": 25 } } ``` So at its core, you've got: - Strings - Numbers - Booleans (`true` or `false`) - Lists - Objects (akin to dictionaries in Python) - Note that objects are not ordered, so the keys could come back in any order. This is not a problem! At the top level of a piece of JSON you can have an object or a list. So this is also valid JSON: ```json [ { "name": "Rolf", "age": 25 }, { "name": "Anne", "age": 27 }, { "name": "Adam", "age": 23 } ] ``` When we return a Python dictionary in a Flask route, Flask automatically turns it into JSON for us, so we don't have to. Remember that "turning it into JSON" means two things: 1. Change Python keywords and values so they match the JSON standard (e.g. `True` to `true`). 2. Turn the whole thing into a single string that our API can return. :::tip Note that JSON can be "prettified" (as the above examples), although usually it is returned by our API "not-prettified": ```json [{"name":"Rolf","age":25},{"name":"Anne","age":27},{"name":"Adam","age":23}] ``` This removal of newlines and spaces, believe it or not, adds up and can save a lot of bandwidth since there is less data to transfer between the API server and the client. ::: ================================================ FILE: docs/docs/03_first_rest_api/05_make_request_to_rest_api/README.md ================================================ --- title: How to interact with your REST API description: Use Postman and Insomnia REST Client to interact with your REST API. ctslug: how-to-interact-with-your-rest-api --- # How to make a request to a REST API One of the most important things about any software development is to make sure that our projects work! So we need to be able to test our project, run it, and make sure it does what we think it does. There are two main ways of doing this: - With automated tests. - With manual, exploratory testing. Usually you'd go with exploratory first, and then you'd make automated tests based on your manual tests. In this course we won't cover automated testing of your REST API (it's a long topic, and we've got another course on it). However, we will cover a lot of things you can do with manual testing. There are two tools I use for exploratory testing: Postman and Insomnia. It's up to you which one to use, but if you haven't used either one before, I recommend Insomnia. It's a bit easier to get started with, and it's free and open source. Start by [downloading Insomnia REST Client](https://insomnia.rest/). Once you've opened it, create a Project. I would call it "REST APIs with Flask and Python". ![Creating the Project for this course](https://res.cloudinary.com/teclado/image/upload/v1689180715/courses/rest-apis-flask-python/creating-project_qsyxlg.png) Then, create a new Request Collection. Call it "Stores REST API". ![Creating the Stores REST API Request Collection](https://res.cloudinary.com/teclado/image/upload/v1689180710/courses/rest-apis-flask-python/making-request-collection_lcthlv.png) In the Request Collection, we can now add requests! Each request has a few parts: - A **method**, such as `GET` or `POST`. The method is just a piece of data sent to the server, but _usually_ certain methods are used for certain things. - The **URL** that you want to request. For our API, this is formed of the "Base URL" (for Flask apps, that's `http://127.0.0.1:5000`), and the endpoint (e.g. `/store`). - The **body**, or any data that you want to send in the request. For example, when creating stores or items we might send some data. - The **headers**, which are other pieces of data with specific names, that the server can use. For example, a header might be sent to help the server understand _who_ is making the request. Let's create our first request, `GET /store`. Make a new request using the Insomnia interface. First, use the dropdown to start: ![How to make a request using the Insomnia interface](https://res.cloudinary.com/teclado/image/upload/v1689180711/courses/rest-apis-flask-python/making-request_hmiptl.png) Then enter the request name. Leave the method as `GET`: ![Enter the request name and method](https://res.cloudinary.com/teclado/image/upload/v1689180712/courses/rest-apis-flask-python/set-request-name-and-method_bc6smy.png) Once you're done, you will see your request in the collection: ![The request is shown in the collection](https://res.cloudinary.com/teclado/image/upload/v1689180711/courses/rest-apis-flask-python/before-setting-url_qjxvyr.png) Next up, enter the URL for your request. Here we will be requesting the `/store` endpoint. Remember to include your Base URL as well: ![Entering the full URL for the request in Insomnia](https://res.cloudinary.com/teclado/image/upload/v1689180714/courses/rest-apis-flask-python/url-set_fgp9s9.png) Once you're done, make sure that your Flask app is running! If it isn't, remember to activate your virtual environment first and then run the app: ``` source .venv/bin/activate flask run ``` :::caution The Flask app will run, by default, on port 5000. If you have another (or the same) app already running, you'll get an error because the port will be "busy". If you get an error, read it carefully and make sure that no other Flask app is running on the same port. ::: Once your Flask app is running, you can hit "Send" on the Insomnia client, and you should see the JSON come back from your API! ![Making a request to our API using Insomnia](https://res.cloudinary.com/teclado/image/upload/v1689180712/courses/rest-apis-flask-python/after-pressing-send_okjkjq.png) If that worked and you can see your JSON, you're good to go! You've made your first API request. Now we can continue developing our REST API, remembering to always create new Requests in Insomnia and test our code as we go along! ================================================ FILE: docs/docs/03_first_rest_api/06_creating_stores/README.md ================================================ --- title: How to create stores description: Learn how to add data to our REST API. ctslug: how-to-create-stores --- # How to create stores in our REST API To create a store, we'll receive JSON from our client (in our case, Insomnia, but it could be another Python app, JavaScript, or any other language or tool). Our client will send us the name of the store they want to create, and we will add it to the database! For this, we will use a `POST` method. `POST` is usually used to receive data from clients and either use it, or create resources with it. In order to access the JSON body of a request, we will need to import `request` from `flask`. Your import list should now look like this: ```py from flask import Flask, request ``` Then, create your endpoint: ```py title="app.py" # highlight-start from flask import Flask, request # highlight-end app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} # highlight-start @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 # highlight-end ``` Here we use `request.get_json()` to retrieve the JSON content of our request. Then we create a new dictionary that represents our store. It has a `name` and `items` (which is an empty list). Then we append this store to our `stores` list. Finally we return the newly created `store`. It's empty, but it serves as a **success message**, to tell our client that we have successfully created what they wanted us to create. :::tip Returning a status code Every response has a status code, which tells the client if the server was successful or not. You already know at least one status code: 404. This means "Not found". The most common status code is `200`, which means "OK". That's what Flask returns by default, such as in the `get_stores()` function. If we want to return a different status code using Flask, we can put it as the second value returned by an endpoint function. In `create_store()`, we are returning the code `201`, which means "Created". ::: ================================================ FILE: docs/docs/03_first_rest_api/07_creating_items/README.md ================================================ --- title: How to create items in each store description: A brief description of the lecture goes here. ctslug: how-to-create-items-in-each-store --- # How to create items in our REST API Next up, let's work on adding items to a store! Here's how that's going to work: 1. The client will send us the store name where they want their new item to go. 2. They will also send us the name and price of the new item. 3. We'll go through the stores one at a time, until we find the correct one (whose name matches what the user gave us). 4. We'll append a new item dictionary to that store's `items`. ## URL parameters There are a few ways for clients to send us data. So far, we've seen that clients can send us JSON. But data can be included in a few other places: - The body (as JSON, form data, plain text, or a variety of other formats). - Inside the URL, part of it can be dynamic. - At the end of the URL, as _query string arguments_. - In the request headers. For this request, the client will send us data in two of these at the same time: the body and the URL. How does a dynamic URL look like? Here's a couple examples: - `/store/My Store/item` - `/store/another-store/item` - `/store/a/item` In those three URLs, the "store name" was: - `My Store` - `another-store` - `a` We can use Flask to define dynamic endpoints for our routes, and then we can grab the value that the client put inside the URL. This allows us to make URLs that make interacting with them more natural. For example, it's nicer to make an item by going to `POST /store/My Store/item`, rather than going to `POST /add-item` and then pass in the store name in the JSON body. To create a dynamic endpoint for our route, we do this: ```py @app.route("/store//item") ``` That makes it so the route function will use a `name` parameter whose value will be what the client put in that part of the URL. Without further ado, let's make our route for creating items within a store! ```py title="app.py" from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "my item", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 # highlight-start @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item return {"message": "Store not found"}, 404 # highlight-end ``` :::tip Not the most efficient way In this endpoint we're iterating over all stores in our list until we find the right one. This is very inefficient, but we'll look at better ways to do this kind of thing when we look at databases. For now, focus on Flask, and don't worry about efficiency of our code! ::: ================================================ FILE: docs/docs/03_first_rest_api/08_return_data_from_rest_api/README.md ================================================ --- title: Get a specific store and its items description: How to use Flask to return data from your REST API to your client. ctslug: get-a-specific-store-and-its-items --- # How to get a specific store and its items The last thing we want to look at in our first REST API is returning data that uses some filtering. Using URL parameters, we can select a specific store: ```py @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 ``` And just as we did when creating an item in a store, you can use the same endpoint (with a `GET` method), to select the items in a store: ```py @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ``` ================================================ FILE: docs/docs/03_first_rest_api/09_final_code/README.md ================================================ --- title: Final code of this section description: Overview of the project we've built and all the code in it. ctslug: final-code-of-this-section --- # Final code of this section Here's everything we've written in this section! ```py title="app.py" from flask import Flask, request app = Flask(__name__) stores = [ { "name": "My Store", "items": [ { "name": "Chair", "price": 15.99 } ] } ] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ``` ================================================ FILE: docs/docs/03_first_rest_api/09_final_code/end/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: docs/docs/03_first_rest_api/Insomnia_section3.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:36:47.360Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_e15dafc098ac4a2198304d2aead2a5b9","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900815265,"created":1666123912423,"url":"http://127.0.0.1:5000/store/My Store/item","name":"/store//item Create item in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Table\",\n\t\"price\": 17.99\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423081,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_8b9c03412d0e463fabe784d205f1d604","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666124528874,"created":1666124528874,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528874,"_type":"request_group"},{"_id":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","parentId":null,"modified":1666991857781,"created":1666122928011,"name":"Section 3","description":"","scope":"collection","_type":"workspace"},{"_id":"req_697ca0714a3d4e94819411e3df0a2a17","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900846590,"created":1666124316888,"url":"http://127.0.0.1:5000/store/My store3/item","name":"/store//item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423056,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9451df3aae714e93a8ed529b3a1f99c2","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666124555354,"created":1666122990495,"url":"http://127.0.0.1:5000/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423031,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6363c8d4deb74b5bbccb1e2105277dac","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900812784,"created":1666124168137,"url":"http://127.0.0.1:5000/store/My store3","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124422956,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0a9c4822679b4eae92dec7432fe144b8","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900810115,"created":1666123651275,"url":"http://127.0.0.1:5000/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store3\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124422881,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_19db457230041d88ca9420d1b3c0f1f02bbcae93","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666122928025,"created":1666122928025,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_19db457230041d88ca9420d1b3c0f1f02bbcae93","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666122928027,"created":1666122928027,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_c5b803a7c6514ff29573e26487d898d4","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666122928018,"created":1666122928018,"fileName":"Your First REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/03_first_rest_api/_category_.json ================================================ { "label": "Your First REST API", "position": 3 } ================================================ FILE: docs/docs/04_docker_intro/01_what_is_docker_container/README.md ================================================ --- ctslug: what-is-a-docker-container description: Learn what Docker images and containers are, and how we can use them to distribute and run our applications. --- # What are Docker images and containers? I'm sure you have heard of the term "Virtual Machine". A virtual machine is an emulation of an Operating System. For example, if you run a Windows virtual machine on your MacOS computer, it will run a whole copy of Windows so you can run Windows programs. This diagram shows what happens in that case: ![Virtual Machine Diagram stack](https://res.cloudinary.com/teclado/image/upload/v1689180716/courses/rest-apis-flask-python/vm.drawio_nlrxmx.png) When you run a Virtual Machine, you can configure what hardware it has access to (e.g. 50% of the host's RAM, 2 CPU cores, etc). Docker containers are a bit different because they don't emulate an Operating System. They use the Operating System kernel of your computer, and run as a process within the host. Containers have their own storage and networking, but because they don't have to emulate the operating system and everything that entails, they are much more lightweight. This diagram shows how Linux containers run in a Linux host: ![Docker Diagram stack](https://res.cloudinary.com/teclado/image/upload/v1689180716/courses/rest-apis-flask-python/docker-linux.drawio_ebvff5.png) Looks similar, but the `docker -> container` section is much more efficient than running a VM because it **uses the host's kernel** instead of running its own. ## What is a kernel? 🍿 An Operating System is made up of two main parts: - The **kernel** - Files and programs that come with the operating system The Linux kernel, for example, is used by all Linux Operating Systems (like Ubuntu, Fedora, Debian, etc.). :::caution Since containers use the host's kernel, you can't run a Windows Docker container natively in a MacOS host. Similarly, you can't run a Linux container natively on Windows or MacOS hosts. ::: ## How to run Linux containers on Windows or MacOS? When you use Docker Desktop (which I'll show you in the next lecture), it runs a Linux Virtual Machine for you, which then is used to run your Linux containers. But aren't you then doing this? ``` hardware -> macos -> hypervisor -> linux vm -> docker -> container -> container program ``` And isn't that much less efficient than just running the program in a Linux virtual machine? Yes. Running Linux containers on MacOS or Windows is "worse" than just running the programs in a Linux VM. However, **99% of the time, you will be running Linux containers in a Linux host, which is much more efficient**. :::tip Why do we always run Linux containers in a Linux host? When you want to deploy your applications to share them with your users, you will almost always be running your app in a Linux server (provided by a _deployment company_, more on that later). There are a few reasons for this. Among them, Linux is free! ::: ## Why are containers more efficient than VMs? From now on let's assume we are running native Linux containers in a Linux host, as that is by far the most common thing to do! When you run a VM, it runs the entire operating system. However, when you run a container it uses part of the host's Operating System (called the kernel). Since the kernel is already running anyway, there is much less work for Docker to do. As a result, containers start up faster, use fewer resources, and need much less hard disk space to run. ## Can you run an Ubuntu image when the host is Linux but not Ubuntu? Since the Linux kernel is the same between distributions, and since Docker containers only use the host's kernel, it doesn't matter which distribution you are running as a host. You can run containers of any distribution with any other distribution as a host. ## How many containers can you run at once? Each container uses layers to specify what files and programs they need. For example, if you run two containers which both use the same version of Python, you'll actually only need to store that Python executable once. Docker will take care of sharing the data between containers. This is why you can run many hundreds of containers in a single host, because there is less duplication of files they use compared to virtual machines. ## What does a Docker container run? If you want to run your Flask app in a Docker container, you need to get (or create) a Docker image that has all the dependencies your Flask app uses, except from the OS kernel: - Python - Dependencies from `requirements.txt` - Possibly `nginx` or `gunicorn` (more on this when we talk about deployment) :::info Aren't there more dependencies? The keen-eyed among you may be thinking: if all you have is the kernel and nothing else, aren't there more dependencies? For example, Python _needs_ the C programming language to run. So shouldn't we need C in our container also? Yes! When we build our Docker image, we will be building it _on top of_ other, pre-built, existing images. Those images come with the lower-level requirements such as compilers, the C language, and most utilities and programs we need. ::: ## What is a Docker image? A Docker image is a snapshot of source code, libraries, dependencies, tools, and everything else (except the Operating System kernel!) that a container needs to run. There are many pre-built images that you can use. For example, some come with Ubuntu (a Linux distribution). Others come with Ubuntu and Python already installed. You can also make your own images that already have Flask installed (as well as other dependencies your app needs). :::info Comes with Ubuntu? In the last lecture I mentioned that Docker containers use the host OS kernel, so why does the container need Ubuntu? Remember that operating systems are kernel + programs/libraries. Although the container uses the host kernel, we may still need a lot of programs/libraries that Ubuntu ships with. An example might be a C language compiler! ::: This is how you define a Docker image. I'll guide you through how to do this in the next lecture, but bear with me for a second: ```dockerfile FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ``` This is a `Dockerfile`, a definition of how to create a Docker image. Once you have this file, you can ask Docker to create the Docker image. Then, after creating the Docker image, you can ask Docker to run it as a container. ``` Dockerfile ---build--> docker image ---run--> docker container ``` In this `Dockerfile` you can see the first line: `FROM python:3.10`. This tells Docker to first download the `python:3.10` image (an image someone else already created), and once that image is created, run the following commands. :::info What's in the Python image? The `python:3.10` image is also built using a `Dockerfile`! You can see the `Dockerfile` for it [here](https://github.com/docker-library/python/blob/master/3.10/bookworm/Dockerfile). You can see it comes `FROM` another image. There is usually a chain of these, images built upon other images, until you reach the base image. In this case, the [base image](https://github.com/docker-library/buildpack-deps/blob/master/debian/bookworm/Dockerfile) is running Debian (a Linux distribution).
Where is the base image!?
If you really want to go deep, you will be able to find... - The [`python3.10:bookworm`](https://github.com/docker-library/python/blob/master/3.10/bookworm/Dockerfile) image builds on `buildpack-deps:bookworm` - [`buildpack-deps:bookworm`](https://github.com/docker-library/buildpack-deps/blob/master/debian/bookworm/Dockerfile) builds on `buildpack-deps:bookworm-scm` - [`buildpack-deps:bookworm-scm`](https://github.com/docker-library/buildpack-deps/blob/master/debian/bookworm/scm/Dockerfile) builds on `buildpack-deps:bookworm-curl` - [`buildpack-deps:bookworm-curl`](https://github.com/docker-library/buildpack-deps/blob/master/debian/bookworm/curl/Dockerfile) builds on `debian:bookworm` - [`debian:bookworm`](https://github.com/debuerreotype/docker-debian-artifacts/blob/f7257ef5b83f6b64385edddeae2d2ba7d1b34935/bookworm/Dockerfile) looks really weird! Eventually, the base image has to physically include the files that make up the operating system. In that last image, that's the Debian OS files that the maintainers have deemed necessary for the `bookworm` image.
So, why the chain? Three main reasons: 1. So you don't have to write a super long and complex `Dockerfile` which contains everything you need. 2. So pre-published images can be shared online, and all you have to do is download them. 3. So when your own images use the same base image, Docker in your computer only downloads the base image once, saving you a lot of disk space. ::: Back to our `Dockerfile`. The commands after `FROM...` are specific to our use case, and do things like install requirements, copy our source code into the image, and tell Docker what command to run when we start a container from this image. This separation between images and containers is interesting because once the image is created you can ship it across the internet and: - Share it with other developers. - Deploy it to servers. Plus once you've downloaded the image (which can take a while), starting a container from it is almost instant since there's very little work to do. ================================================ FILE: docs/docs/04_docker_intro/02_run_docker_container/README.md ================================================ --- ctslug: how-to-run-a-docker-container description: Learn how to run a Docker container with your REST API using Docker Desktop. --- # How to run a Docker container ## Install Docker Desktop Docker Desktop is an application to help you manage your images and containers. Download it and install it here: [https://www.docker.com/products/docker-desktop/](https://www.docker.com/products/docker-desktop/). ## Create your Docker image Next, download the REST API code from Section 3. You can download it [here](https://www.dropbox.com/s/qs28amk2h420f2y/s03-final-code.zip?dl=0). If you want to use the code you wrote while following the videos, that's fine! Just make sure it works by running the Flask app locally and testing it with Insomnia REST Client or Postman. ### Write the `Dockerfile` In your project folder (i.e. the same folder as `app.py`), we're going to write the Dockerfile. To do this, make a file called `Dockerfile`. :::caution Make sure the file is called `Dockerfile`, and not `Dockerfile.txt` or anything like that! ::: Inside the `Dockerfile` we're going to write this: ```dockerfile FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ``` Here's a quick breakdown of what each line does: 1. `FROM python:3.10` uses the `python:3.10` image as a base. 2. `EXPOSE 5000` is basically documentation[^1]. It tells the user of the Dockerfile that port 5000 is something the running container will use. 3. `WORKDIR /app` does it so everything we do in the Docker image will happen in the image's `/app` directory. 4. `RUN pip install flask` runs a command in the image. Here the command is `pip install flask`, which is what we need to run our app. 5. `COPY . .` is a bit cryptic! It copies everything in the current folder (so `app.py`) into the image's current folder (so `/app`). 6. `CMD ["flask", "run", "--host", "0.0.0.0"]` tells the image what command to run when you start a container. Here the command is `flask run --host=0.0.0.0`. :::tip We need `--host=0.0.0.0` to make Docker be able to do port forwarding, as otherwise the Flask app will only be accessible within the container, but not outside the container. ::: Now we need to create the Docker image. We do this with the `docker build` command in the terminal. :::caution Make sure to restart your terminal after installing Docker Desktop, so that you have access to the `docker` program in your terminal. ::: Open a terminal (in VSCode that's CMD+J or CTRL+J), and run this command: ``` docker build -t rest-apis-flask-python . ``` The `-t rest-apis-flask-python` flag is optional, but tags the image, giving it a name. It can be handy for later! The final `.` at the end of the command is not a mistake; it tells the command _what_ to build. The `.` means "the current directory". This command can take a while to run as it needs to download the `python:3.10` image first. You should see quite a lot of output while the command runs. When the command is finished, you should see this (among other things): ``` => [2/4] WORKDIR /app 0.4s => [3/4] RUN pip install flask 2.9s => [4/4] COPY . . 0.0s => exporting to image 0.1s => => exporting layers 0.1s => => writing image sha256:d9a68a03f868e74bca48567dfc9a0b702d1618941a71b77de12ff14e908ba155 0.0s => => naming to docker.io/library/rest-apis-first-rest-api 0.0s ``` And now your image is built! You should be able to see it in the "Images" section of your Docker Desktop app. ## Run the Docker container When we start a Docker container from this image, it will run the `flask run` command. Remember that by default, `flask run` starts a Flask app using port 5000. But the container's ports are not accessible from outside the container by default. We need to tell Docker that when we access a certain port in our computer, those requests and responses should be forwarded to a certain port in our container. So we'll run the container, but we must remember to forward a port (e.g. 5000) in our computer to port 5000 in the container To do so, run this command: ``` docker run -d -p 5000:5000 rest-apis-flask-python ``` We're passing a few things to `docker run`: 1. `-d` runs the container in the background, so that you can close the terminal and the container keeps running. 2. `-p 5000:5000` maps port 5000 in your computer to port 5000 in the container. 3. `rest-apis-flask-python` is the image tag that you want to run. You should see something like this as your output: ``` 9f3c564ac64a1723069dda0e80becb70d3697d4bfcbcb626cd5add0c65df173f ``` That's the ID of the container. If you're not using Docker Desktop, you need this ID in order to stop the container later (with `docker rm 9f3c564`, that's the first few characters of the ID). And now, if everything has worked, you should be able to access the Flask app _just as if it was running without Docker_! :::caution Did something not work? A common error can happen when the port that you tried to forward isn't available (e.g. because something else is already running): ``` docker: Error response from daemon: driver failed programming external connectivity on endpoint bold_goldwasser (ff58b1755c1d1d0fd6b1dd4f59ab3b903b0e68f320624c4a2495672a735039d5): Bind for 0.0.0.0:5000 failed: port is already allocated. ``` You have two options: either figure out what is running on port 5000 and shut it down before trying again, or you can change the port that you want to use in your computer: ``` docker run -dp 5001:5000 rest-apis-flask-python ``` ::: Try making requests using the URL `127.0.0.1:5000` with Insomnia REST Client or Postman, and you should see it working well! ![Insomnia REST Client successfully made a request to the API running in Docker](https://res.cloudinary.com/teclado/image/upload/v1689180719/courses/rest-apis-flask-python/running-app-docker_mkosjm.png) [^1]: [Docker `EXPOSE` command (Official Documentation)](https://docs.docker.com/engine/reference/builder/#expose) ================================================ FILE: docs/docs/04_docker_intro/02_run_docker_container/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/04_docker_intro/02_run_docker_container/end/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: docs/docs/04_docker_intro/02_run_docker_container/start/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: docs/docs/04_docker_intro/03_in_depth_docker_tutorial/README.md ================================================ --- ctslug: in-depth-docker-tutorial description: My notes from the official Docker tutorial. --- # In-depth Docker Tutorial Like I mentioned earlier on in this section, this course is not a Docker course! You can access the official Docker tutorial (which is free and great) by running the tutorial image: ``` docker run -dp 80:80 docker/getting-started ``` Then you can access http://127.0.0.1/tutorial to launch the official tutorial. I recommend going through this (although it uses NodeJS as an example 🤮), as it teaches you quite a few important commands and concepts, such as working with volumes and layers. When I went through the official tutorial I took some notes, which you can see below. Remember these may differ from the official tutorial as the Docker team updates the tutorial regularly. I hope the notes are helpful as a bit of a cheatsheet, but it doesn't beat going through the tutorial yourself and taking your own notes! --- ## How to write a simple Dockerfile for a Node app ```dockerfile FROM node:12-alpine # Adding build tools to make yarn install work on Apple silicon / arm64 machines RUN apk add --no-cache python2 g++ make WORKDIR /app COPY . . RUN npm install CMD ["node", "src/index.js"] ``` Then build the image into a new container (the `.` below refers to the current directory, where Docker should find the `Dockerfile`). Optionally tag it: ``` docker build -t docker-image-tag . ``` ## How to run Docker as a daemon (background) This prints out the container ID and runs it in the background. ``` docker run -d docker-image-tag ``` ## How to map ports from host machine to Docker container This binds port 5000 of the Docker image to port 3000 of the host machine. This way you when you access `127.0.0.1:3000` with your browser, you'll access whatever the Docker image is serving in port `5000`. Docs: https://docs.docker.com/engine/reference/commandline/run/#publish-or-expose-port--p---expose ``` docker run -p 127.0.0.1:3000:5000 docker-image-tag ``` ## Working with Docker volumes In a Docker volume, the Docker container can store data in the Docker container's filesystem, and it is actually stored in the volume (which is a location in the host machine). This is in contrast to a Bind Mount, which is another type of volume where the Docker container reads files (i.e. is provided files to read) from the host machine. The Docker container cannot modify those files when using Bind Mounts. | Feature | Named Volumes | Bind Mounts | | -------------------------------------------- | --------------------------- | ------------------------------- | | Host Location | Docker chooses | You control | | Mount Example (using `v`) | `my-volume:/usr/local/data` | `/path/to/data:/usr/local/data` | | Populates new volume with container contents | Yes | No | | Supports Volume Drivers | Yes | No | ### How to map a Named Volume from host to Docker container ``` docker run -v volume-name:/path/in/docker/image container-tag ``` For example for an app that needs port mapping and a volume: ``` docker run -dp 3000:3000 -v todo-db:/etc/todos getting-started ``` And to see _where_ in the host machine the data is actually stored: ``` docker volume inspect volume-name ``` > While running in Docker Desktop, the Docker commands are actually running inside a small VM on your machine. If you wanted to look at the actual contents of the Mountpoint directory, you would need to first get inside of the VM. ### How to use a Bind Mount to provide your app code to a Docker container ``` docker run -dp 3000:3000 \ -w /app -v "$(pwd):/app" \ node:12-alpine \ sh -c "apk add --no-cache python2 g++ make && yarn install && yarn run dev" ``` - `-dp 3000:3000` - same as before. Run in detached (background) mode and create a port mapping - `-w /app` - sets the container's present working directory where the command will run from - `-v "$(pwd):/app"` - bind mount (link) the host's present `getting-started/app` directory to the container's `/app` directory. Note: Docker requires absolute paths for binding mounts, so in this example we use `pwd` for printing the absolute path of the working directory, i.e. the `app` directory, instead of typing it manually - `node:12-alpine` - the image to use. Note that this is the base image for our app from the Dockerfile - `sh -c "yarn install && yarn run dev"` - the command. We're starting a shell using `sh` (alpine doesn't have `bash`) and running `yarn install` to install _all_ dependencies and then running `yarn run dev`. If we look in the `package.json`, we'll see that the `dev` script is starting `nodemon`. Note that most of this is identical to the `Dockerfile` that you would create for your project. The only difference is the `-v "$(pwd):/app"` flag. ## How to pass environment variables to a container Use the `-e ENV_NAME=env_value` flag with `docker run`. :::caution Secrets in environment variables Passing secrets like database connection strings or API keys to Docker containers can be done with environment variables, but it isn't the most secure way (the official Docker tutorial [will tell you more](https://diogomonica.com/2017/03/27/why-you-shouldnt-use-env-variables-for-secret-data/)). Instead a better option is to use your orchestration framework's secrets management system (that's a mouthful). The two major options are [Kubernetes](https://kubernetes.io/docs/concepts/configuration/secret/) and [Swarm](https://docs.docker.com/engine/swarm/secrets/), and each have their own secrets management system. More info on this later on! ::: ## Networking between two containers First create a network with: ``` docker network create network-name ``` Then pass the `--network network-name` flag to `docker run`. You can also pass `--network-alias` to `docker run` to give the container you are running a DNS name within the network. Then create your containers and pass the network to them. For example, this starts up a MySQL image on `linux/amd64`. It also creates a volume and passes in two environment variables which the image uses for configuring MySQL: ``` docker run -d \ --network network-name --network-alias mysql --platform linux/amd64 \ -v todo-mysql-data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql_root_password \ -e MYSQL_DATABASE=todos \ mysql:5.7 ``` Then you could run another container on the same network: ``` docker run -dp 3000:3000 \ -w /app -v "$(pwd):/app" \ --network network-name \ -e MYSQL_HOST=mysql \ -e MYSQL_USER=root \ -e MYSQL_PASSWORD_FILE=/run/secrets/mysql_password \ -e MYSQL_DB=todos \ node:12-alpine \ sh -c "npm install && npm run dev" ``` :::caution In these I'm not passing the MySQL password directly as an environment variable. Instead, I'm passing the path to a file that contains the password. That file is created by your Docker orchestration framework's secrets management system. That's a mouthful to say: you define the secret in your orchestration framework, and the framework creates a file which contains the password. That way, the password isn't stored in the environment which is a bit unsafe. Your application (or, in this case, MySQL), would have to read the contents of the image to find the password. More info on this when we learn about deploying our app in production! ::: ## How to run multiple containers using Docker Compose 1. Create a `docker-compose.yml` file in the root of your project. 2. Turn each of the `docker run` commands into a `service` in the `docker-compose.yml` file. 3. This is re-creating the flags passed to the `docker run` command, but in YAML format. Example of the two `docker run`s above: ```yml services: app: image: node:12-alpine command: sh -c "npm install && npm run dev" ports: - 3000:3000 working_dir: /app volumes: - ./:/app environment: MYSQL_HOST: mysql MYSQL_USER: root MYSQL_PASSWORD_FILE: /run/secrets/mysql_password MYSQL_DB: todos mysql: image: mysql:5.7 platform: linux/amd64 volumes: - todo-mysql-data:/var/lib/mysql environment: MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password MYSQL_DATABASE: todos volumes: todo-mysql-data: ``` Then, just run `docker compose up -d` and it will start in the background! You can see it in Docker desktop. Tear it down and remove the containers (but not the volumes) with `docker compose down`. ## Caching in Dockerfile layers Each layer (i.e. each line of text) in a Dockerfile uses caching. That means that if Docker doesn't detect that a layer has changed, it won't re-run it. It'll use the last value / files that were generated for the last build. However, it also means that if one layer changes and has to be re-built, Docker will re-build all subsequent layers. Therefore it's best to set up your Dockerfile so that you can maximise the amount of caching and reduce the chances of a cache bust. For example, instead of this: ``` FROM node:12-alpine WORKDIR /app COPY . . RUN npm install --production CMD ["node", "src/index.js"] ``` You might do this: ``` FROM node:12-alpine WORKDIR /app COPY package.json package.lock ./ RUN npm install --production COPY . . CMD ["node", "src/index.js"] ``` That way if the `package.json` and `package.lock` files don't change, you won't re-run `npm install`. In the first example, if _any_ code files changed, `npm install` would run. Even if it was not needed because the requirements file didn't change. ### Ignore certain files and folders with `.dockerignore` Some files and folders can be safely ignored when copying over to the Docker container. For example, `node_modules` or the Python virtual environment. Create a `.dockerignore` file in the root directory of your project (where `docker-compose.yml` lives), and add this (more examples of what to add for a Python project [here](https://github.com/GoogleCloudPlatform/getting-started-python/blob/main/optional-kubernetes-engine/.dockerignore)): ``` node_modules .venv .env *.pyc __pycache__ ``` :::danger Secrets in Docker images Don't include any secrets (like database connection strings or API keys) in your code. For local development you can use a `.env` file, but don't include the `.env` file in your Docker image! One of the benefits of Docker images is you can share them with others easily, but that's why you have to be very careful with what you include in them. ::: ================================================ FILE: docs/docs/04_docker_intro/04_run_with_docker_compose/README.md ================================================ # Run the REST API using Docker Compose Now that we've got a Docker container for our REST API, we can set up Docker Compose to run the container. Docker Compose is most useful to start multiple Docker containers at the same time, specifying configuration values for them and dependencies between them. Later on, I'll show you how to use Docker Compose to start both a PostgreSQL database and the REST API. For now, we'll use it only for the REST API, to simplify starting its container up. If you have Docker Desktop installed, you already have Docker Compose. If you want to install Docker Compose in a system without Docker Desktop, please refer to the [official installation instructions](https://docs.docker.com/compose/install/). ## How to write a `docker-compose.yml` file Create a file called `docker-compose.yml` in the root of your project (alongside your `Dockerfile`). Inside it, add the following contents: ```yaml services: web: build: . ports: - "5000:5000" volumes: - .:/app ``` This small file is all you need to tell Docker Compose that you have a service, called `web`, which is built using the current directory (by default, that looks for a file called `Dockerfile`). Other settings provided are: - `ports`, used to map a port in your local computer to one in the container. Since our container runs the Flask app on port 5000, we're targeting that port so that any traffic we access in port 5000 of our computer is sent to the container's port 5000. - `volumes`, to map a local directory into a directory within the container. This makes it so you don't have to rebuild the image each time you make a code change. ## How to run the Docker Compose services Simply type: ``` docker compose up ``` And that will start all your services. For now, there's just one service, but later on when we add a database, this command will start everything. When the services are running, you'll start seeing logs appear. These are the same logs as for running the `Dockerfile` on its own, but preceded by the service name. In our case, we'll see `web-1 | ...` and the logs saying the service is running on `http://127.0.0.1:5000`. When you access that URL, you'll see the request logs printed in the console. Congratulations, you've ran your first Docker Compose service! ## Rebuilding the Docker image If you need to rebuild the Docker image of your REST API service for whatever reason (e.g. configuration changes), you can run: ``` docker compose up --build --force-recreate --no-deps web ``` More information [here](https://stackoverflow.com/a/50802581). ================================================ FILE: docs/docs/04_docker_intro/04_run_with_docker_compose/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/04_docker_intro/04_run_with_docker_compose/end/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: docs/docs/04_docker_intro/04_run_with_docker_compose/end/docker-compose.yml ================================================ services: web: build: . ports: - "5000:5000" volumes: - .:/app ================================================ FILE: docs/docs/04_docker_intro/04_run_with_docker_compose/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/04_docker_intro/04_run_with_docker_compose/start/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: docs/docs/04_docker_intro/05_run_commands_in_docker_containers/README.md ================================================ # How to run commands inside a Docker container If you run your API using Docker Compose, with the `docker compose up` command, you may also want to be able to execute arbitrary shell commands in the container. For example, later on in the course we will look at database migrations. To execute a database migration, we need to run a specific command, `flask db mgirate`. If we use Docker Compose, we'll need to run the command inside the running container, and not in a local terminal. You can run any arbitrary command in a running container like so: ```bash docker compose exec web flask db migrate ``` This command is split into 4 parts: - `docker compose`: uses the Docker Compose part of the Docker executable - `exec`: used to run a command in a specific Docker Compose service - `web`: which Docker Compose service to run the command in - `flask db migrate`: the command you want to run That's all! Just remember while following the course, that if I run any commands in my local terminal and you are using Docker Compose, you should precede the commands with `docker compose exec web`. ================================================ FILE: docs/docs/04_docker_intro/README.md ================================================ # An Introduction to Docker :::caution Not a Docker course An important foreword: this is not a Docker course, and I'm not a Docker expert! In this section, and in later sections of this course, I'll teach you what Docker is and how to use it to run and deploy your Flask apps. However, I won't teach you everything there is to know about Docker! ::: Docker is a software framework for building, running, and managing **images** and **containers**. In order to understand Docker, you need to clarify two questions: - What are Docker containers, and how are they different to Virtual Machines? - What are Docker images? After this, you'll be ready to create your own Docker images and use those images to create and run containers. Let's take a look at Docker containers in the next lecture! ================================================ FILE: docs/docs/04_docker_intro/_category_.json ================================================ { "label": "Introduction to Docker", "position": 4 } ================================================ FILE: docs/docs/05_flask_smorest/01_why_flask_smorest/README.md ================================================ --- ctslug: why-use-flask-smorest --- # Why use Flask-Smorest There are many different REST API libraries for Flask. In a previous version of this course, we used Flask-RESTful. Now, I recommend using [Flask-Smorest](https://github.com/marshmallow-code/flask-smorest). Over the last few months, I've been trialing the major REST libraries for Flask. I've built REST APIs using Flask-RESTful, Flask-RESTX, and Flask-Smorest. I was looking to compare the three libraries in a few key areas: - **Ease of use and getting started**. Many REST APIs are essentially microservices, so being able to whip one up quickly and without having to go through a steep learning curve is definitely interesting. - **Maintainability and expandability**. Although many start as microservices, sometimes we have to maintain projects for a long time. And sometimes, they grow past what we originally envisioned. - **Activity in the library itself**. Even if a library is suitable now, if it is not actively maintained and improved, it may not be suitable in the future. We'd like to teach something that you will use for years to come. - **Documentation and usage of best practice**. The library should help you write better code by having strong documentation and guiding you into following best practice. If possible, it should use existing, actively maintained libraries as dependencies instead of implementing their own versions of them. - **Developer experience in production projects**. The main point here was: how easy is it to produce API documentation with the library of choice. Hundreds of students have asked me how to integrate Swagger in their APIs, so it would be great if the library we teach gave it to you out of the box. ## Flask-Smorest is the most well-rounded It ticks all the boxes above: - If you want, it can be super similar to Flask-RESTful (which is a compliment, really easy to get started!). - It uses [marshmallow](https://marshmallow.readthedocs.io/en/stable/) for serialization and deserialization, which is a huge plus. Marshmallow is a very actively-maintained library which is very intuitive and unlocks very easy argument validation. Unfortunately Flask-RESTX [doesn't use marshmallow](https://flask-restx.readthedocs.io/en/latest/marshalling.html), though there are [plans to do so](https://github.com/python-restx/flask-restx/issues/59). - It provides Swagger (with Swagger UI) and other documentations out of the box. It uses the same marshmallow schemas you use for API validation and some simple decorators in your code to generate the documentation. - The documentation is the weakest point (compared to Flask-RESTX), but with this course we can help you navigate it. The documentation of marshmallow is superb, so that will also help. ## If you took an old version of this course... Let me tell you about some of the key differences between a project that uses Flask-RESTful and one that uses Flask-Smorest. After reading through these differences, it should be fairly straightforward for you to look at two projects, each using one library, and compare them. 1. Flask-Smorest uses `flask.views.MethodView` classes registered under a `flask_smorest.Blueprint` instead of `flask_restful.Resource` classes. 2. Flask-Smorest uses `flask_smorest.abort` to return error responses instead of manually returning the error JSON and error code. 3. Flask-Smorest projects define marshmallow schemas that represent incoming data (for deserialization and validation) and outgoing data (for serialization). It uses these schemas to automatically validate the data and turn Python objects into JSON. Throughout this section I'll show you how to implement these 3 points in practice, so if you've already got a REST API that uses Flask-RESTful, you'll find it really easy to migrate. Of course, you can keep using Flask-RESTful for your existing projects, and only use Flask-Smorest for new projects. That's also an option! Flask-RESTful isn't abandoned or deprecated, so it's still a totally viable option. ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/README.md ================================================ --- title: "Data model improvements" description: "Use dictionaries instead of lists for data storage, and store stores and items separately." ctslug: data-model-improvements --- # Data model improvements ## Starting code from section 4 This is the "First REST API" project from Section 4: import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
```py title="app.py" from flask import Flask, request app = Flask(__name__) stores = [ { "name": "My Store", "items": [ { "name": "Chair", "price": 15.99 } ] } ] @app.get("/store") # http://127.0.0.1:5000/store def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ``` ```docker FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ```
## New files :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! There are two Insomnia files for this section: one for lectures 1-5 (before adding Docker), and one for the other lectures (after adding Docker). ::: Let's start off by creating a `requirements.txt` file with all our dependencies: ```txt title="requirements.txt" flask flask-smorest python-dotenv ``` We're adding `flask-smorest` to help us write REST APIs more easily, and generate documentation for us. We're adding `python-dotenv` so it's easier for us to load environment variables and use the `.flaskenv` file. Next, let's create the `.flaskenv` file: ```txt title=".flaskenv" FLASK_APP=app FLASK_DEBUG=True ``` If we have the `python-dotenv` library installed, when we run the `flask run` command, Flask will read the variables inside `.flaskenv` and use them to configure the Flask app. The configuration that we'll do is to define the Flask app file (here, `app.py`). Then we'll also set the `FLASK_DEBUG` flag to `True`, which does a couple things: - Makes the app give us better error messages and return a traceback when we make requests if there's an error. - Sets the app reloading to true, so the app restarts when we make code changes We don't want debug mode to be enabled in production (when we deploy our app), but while we're doing development it's definitely a time-saving tool! ## Code improvements ### Creating a database file First of all, let's move our "database" to another file. Create a `db.py` file with the following content: ```py title="db.py" stores = {} items = {} ``` In the existing code we only have a `stores` list, so delete that from `app.py`. From now on we will be storing information about items and stores separately. :::tip What is in each dictionary? Each dictionary will closely mimic how a database works: a mapping of ID to data. So each dictionary will be something like this: ```py { 1: { "name": "Chair", "price": 17.99 }, 2: { "name": "Table", "price": 180.50 } } ``` This will make it much easier to retrieve a specific store or item, just by knowing its ID. ::: Then, import the `stores` and `items` variables from `db.py` in `app.py`: ```py title="app.py" from db import stores, items ``` ## Using stores and items in our API Now let's make use of stores and items separately in our API. ### `get_store` Here are the changes we'll need to make:
```py title="app.py" @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 ``` ```py title="app.py" @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: return {"message": "Store not found"}, 404 ``` Important to note that in this version, we won't return the items in the store. That's a limitation of our dictionaries-for-database setup that we will solve when we introduce databases!
### `get_stores`
```py title="app.py" @app.get("/store") def get_stores(): return {"stores": stores} ``` ```py title="app.py" @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ```
### `create_store`
```py title="app.py" @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 ``` ```py title="app.py" import uuid @app.post("/store") def create_store(): store_data = request.get_json() store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ``` Here we add a new import, [the `uuid` module](https://docs.python.org/3/library/uuid.html). We will be using it to create unique IDs for our stores and items instead of relying on the uniqueness of their names.
### `create_item`
```py title="app.py" @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 ``` ```py title="app.py" @app.post("/item") def create_item(): item_data = request.get_json() if item_data["store_id"] not in stores: return {"message": "Store not found"}, 404 item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ``` Now we are POSTing to `/item` instead of `/store//item`. The endpoint will expect to receive JSON with `price`, `name`, and `store_id`.
### `get_items` (new) This is not an endpoint we could easily make when we were working with a single `stores` list! ```py @app.get("/item") def get_all_items(): return {"items": list(items.values())} ``` ### `get_item_in_store`
```py title="app.py" @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ``` ```py title="app.py" @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: return {"message": "Item not found"}, 404 ``` Now we are GETting from `/item` instead of `/store//item`. This is because while items are related to stores, they aren't inside a store anymore!
================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/end/Dockerfile ================================================ # In the course we run the app outside Docker # until lecture 5. FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/end/app.py ================================================ import uuid from flask import Flask, request from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: return {"message": "Item not found"}, 404 @app.post("/item") def create_item(): item_data = request.get_json() if item_data["store_id"] not in stores: return {"message": "Store not found"}, 404 item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: return {"message": "Store not found"}, 404 @app.post("/store") def create_store(): store_data = request.get_json() store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/end/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/02_data_model_improvements/start/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [{"name": "My Store", "items": [{"name": "Chair", "price": 15.99}]}] @app.get("/store") # http://127.0.0.1:5000/store def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/README.md ================================================ --- title: "Improvements to our first REST API" description: "Add new error handling and code improvements to the REST API before adding any new endpoints." ctslug: improvements-to-our-first-rest-api --- # Improvements to our first REST API ## Using `flask_smorest.abort` instead of returning errors manually At the moment in our API we're doing things like these in case of an error: ```py title="app.py" @app.get("/store/") def get_store(name): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: return {"message": "Store not found"}, 404 ``` A small improvement we can do on this is use the `abort` function from Flask-Smorest, which helps us write these messages and include a bit of extra information too. Add this import at the top of `app.py`: ```py title="app.py" from flask_smorest import abort ``` And then let's change our error returns to use `abort`. ```py title="app.py" @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: # highlight-start abort(404, message="Store not found.") # highlight-end ``` And here: ```py title="app.py" @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: # highlight-start abort(404, message="Item not found.") # highlight-end ``` ## Adding error handling on creating items and stores At the moment when we create items and stores, we _expect_ there to be certain items in the JSON body of the request. If those items are missing, the app will return an error 500, which means "Internal Server Error". Instead of that, it's good practice to return an error 400 and a message telling the client what went wrong. To do so, let's inspect the body of the request and see if it contains the data we need. Let's change our `create_item()` function to this: ```py title="app.py" @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ``` And our `create_store()` function to this: ```py title="app.py" @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ``` ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/app.py ================================================ import uuid from flask import Flask, request from flask_smorest import abort from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: abort(404, message="Store not found.") @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/end/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/app.py ================================================ import uuid from flask import Flask, request from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: return {"message": "Item not found"}, 404 @app.post("/item") def create_item(): item_data = request.get_json() if item_data["store_id"] not in stores: return {"message": "Store not found"}, 404 item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: return {"message": "Store not found"}, 404 @app.post("/store") def create_store(): store_data = request.get_json() store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/03_improvements_on_first_rest_api/start/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/README.md ================================================ --- title: "New endpoints for our REST API" description: "Let's add a few routes to our first REST API, so it better matches what a production REST API would look like." ctslug: new-endpoints-for-our-rest-api --- # New endpoints for our REST API ## New endpoints We want to add some endpoints for added functionality: - `DELETE /item/` so we can delete items from the database. - `PUT /item/` so we can update items. - `DELETE /store/` so we can delete stores. ### Deleting items This is almost identical to getting items, but we use the `del` keyword to remove the entry from the dictionary. ```py title="app.py" @app.delete("/item/") def delete_item(item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") ``` ### Updating items This is almost identical to creating items, but in this API we've decided to not let item updates change the `store_id` of the item. So clients can change item name and price, but not the store that the item belongs to. This is an API design decision, and you could very well allow clients to update the `store_id` if you want! ```py title="app.py" @app.put("/item/") def update_item(item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # You should also prevent keys that aren't 'price' or 'name' to be passed # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] item |= item_data return item except KeyError: abort(404, message="Item not found.") ``` :::tip Dictionary update operators The `|=` syntax is a new dictionary operator. You can read more about it [here](https://blog.teclado.com/python-dictionary-merge-update-operators/). ::: ### Deleting stores This is very similar to deleting items! ```py title="app.py" @app.delete("/store/") def delete_store(store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") ``` ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/app.py ================================================ import uuid from flask import Flask, request from flask_smorest import abort from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.delete("/item/") def delete_item(item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @app.put("/item/") def update_item(item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: abort(404, message="Store not found.") @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.delete("/store/") def delete_store(store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/end/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/app.py ================================================ import uuid from flask import Flask, request from flask_smorest import abort from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: abort(404, message="Store not found.") @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/04_new_endpoints_for_api/start/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/README.md ================================================ --- title: "Reloading API code in Docker container" description: "Learn how to get your code instantly synced up to the Docker container, so that every time you make a code change it restarts the app in the container and uses the latest code." ctslug: reloading-api-code-in-docker-container --- # Reloading API code in Docker container ## Updating Dockerfile to use `requirements.txt` This is the Dockerfile as we've got it: ```dockerfile FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ``` But there is a problem! It doesn't use the `requirements.txt`, so it only installs Flask as a dependency. We want to add `requirements.txt` and install the dependencies from it. You might be tempted to move the `COPY` line above the `RUN` line, and then install it with `pip install -r requirements.txt`. But there's a better way! ```dockerfile FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ``` Here we: - Add a new `COPY` line that copies the `requirements.txt` file into the image. This creates a new cached layer, so that if the `requirements.txt` file doesn't change, this line and the following `RUN` line don't run again. - Change the `pip install` code to use `--no-cache-dir --upgrade`. This makes sure that we don't use any pre-existing pip caches when installing, and also upgrades libraries to the latest version if necessary. ## Running the container with volumes for hot reloading Up to now, we've been re-building the Docker image and re-running the container each time we make a code change. This is a bit of a time sink, and a bit annoying to do! Let's do it so that the Docker container runs the code that we're editing. That way, when we make a change to the code, the Flask app should restart and use the new code. All we have to do is: 1. Build the Docker image 2. Run the image, but replace the contents of the image's `/app` directory (where the code is) by the contents of our source code folder in the host machine. So, first build the Docker image: ``` docker build -t flask-smorest-api . ``` Once that's done, the image has an `/app` directory which contains the source code as it was copied from the host machine during the build stage. So at this point, we _can_ run a container from this image, and it will run the app _as it was when it was built_: ``` docker run -dp 5000:5000 flask-smorest-api ``` This should just work, and you can try it out in the Insomnia REST Client to make sure the endpoints all work. But like we said earlier, when we make changes to the code we'll have to rebuild and rerun. So instead, what we can do is run the image, but replace the image's `/app` directory with the host's source code folder. That will cause the source code to change in the Docker container while it's running. And, since we've ran Flask with debug mode on, the Flask app will automatically restart when the code changes. To do so, stop the running container (if you have one running), and use this command instead: ``` docker run -dp 5000:5000 -w /app -v "$(pwd):/app" flask-smorest-api ``` :::info Windows command The command on Windows varies depending on what terminal application you use. Here are some of the most popular ones! **PowerShell** ``` docker run -dp 5000:5000 -w //app -v "$(Get-Location)://app" flask-smorest-api ``` **Git Bash** ``` docker run -dp 5000:5000 -w //app -v "//$(pwd)://app" flask-smorest-api ``` **Command Prompt (CMD)** ``` docker run -dp 5000:5000 -w //app -v "%cd%://app" flask-smorest-api ``` ::: - `-dp 5000:5000` - same as before. Run in detached (background) mode and create a port mapping. - `-w /app` - sets the container's present working directory where the command will run from. - `-v "$(pwd):/app"` - bind mount (link) the host's present directory to the container's `/app` directory. Note: Docker requires absolute paths for binding mounts, so in this example we use `pwd` for printing the absolute path of the working directory instead of typing it manually. - `flask-smorest-api` - the image to use. And with this, your Docker container now is running the code as shown in your IDE. Plus, since Flask is running with debug mode on, the Flask app will restart when you make code changes! :::info Using this kind of volume mapping only makes sense _during development_. When you share your Docker image or deploy it, you won't be sharing anything from the host to the container. That's why it's still important to include the original source code in the image when you build it. ::: Just to recap, here are the two ways we've seen to run your Docker container: ![Diagram showing two ways of running a Docker container from a built image, with and without volume mapping](https://res.cloudinary.com/teclado/image/upload/v1689180724/courses/rest-apis-flask-python/build-with-without-volume_a7mig8.png) ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/end/app.py ================================================ import uuid from flask import Flask, request from flask_smorest import abort from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.delete("/item/") def delete_item(item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @app.put("/item/") def update_item(item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: abort(404, message="Store not found.") @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.delete("/store/") def delete_store(store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/end/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/start/app.py ================================================ import uuid from flask import Flask, request from flask_smorest import abort from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.delete("/item/") def delete_item(item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @app.put("/item/") def update_item(item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.get("/store/") def get_store(store_id): try: # Here you might also want to add the items in this store # We'll do that later on in the course return stores[store_id] except KeyError: abort(404, message="Store not found.") @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store @app.delete("/store/") def delete_store(store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @app.get("/store") def get_stores(): return {"stores": list(stores.values())} ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/05_reload_api_docker_container/start/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/README.md ================================================ --- title: How to use Blueprints and MethodViews description: Flask-Smorest MethodViews allow us to simplify API Resources by defining all methods that interact with the resource in one Python class. ctslug: how-to-use-flask-smorest-methodviews-blueprints --- # How to use Flask-Smorest MethodViews and Blueprints Let's improve the structure of our code by splitting items and stores endpoints into their own files. Let's create a `resources` folder, and inside it create `item.py` and `store.py`. ## Creating a blueprint for each related group of resources ### `resources/store.py` Let's start in `store.py`, and create a `Blueprint`: ```py title="resources/store.py" import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores blp = Blueprint("stores", __name__, description="Operations on stores") ``` The `Blueprint` arguments are the same as the Flask `Blueprint`[^1], with an added optional `description` keyword argument: 1. `"stores"` is the name of the blueprint. This will be shown in the documentation and is prepended to the endpoint names when you use `url_for` (we won't use it). 2. `__name__` is the "import name". 3. The `description` will be shown in the documentation UI. Now that we've got this, let's add our `MethodView`s. These are classes where each method maps to one endpoint. The interesting thing is that method names are important: ```py title="resources/store.py" @blp.route("/store/") class Store(MethodView): def get(self, store_id): pass def delete(self, store_id): pass ``` Two things are going on here: 1. The endpoint is associated to the `MethodView` class. Here, the class is called `Store` and the endpoint is `/store/`. 2. There are two methods inside the `Store` class: `get` and `delete`. These are going to map directly to `GET /store/` and `DELETE /store/`. Now we can copy the code from earlier into each of the methods: ```py title="resources/store.py" @blp.route("/store/") class Store(MethodView): def get(self, store_id): try: return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(self, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") ``` Now, still inside the same file, we can add another `MethodView` with a different endpoint, for the `/store` route: ```py title="resources/store.py" @blp.route("/store") class StoreList(MethodView): def get(self): return {"stores": list(stores.values())} def post(self): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ``` ### `resources/item.py` Let's do the same thing with the `resources/item.py` file: ```py title="resources/item.py" import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") def put(self, item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} def post(self): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ``` ## Import blueprints and Flask-Smorest configuration Finally, we have to import the `Blueprints` inside `app.py`, and register them with Flask-Smorest: ```py title="app.py" from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ``` I've also added a few config variables to the `app.config`. The `PROPAGATE_EXCEPTIONS` value is used so that when an exception is raised in an extension, it is bubbled up to the main Flask app so you'd see it more easily. The other config values are there for the documentation of our API, and they define things such as the API name and version, as well as information for the Swagger UI. Now you should be able to go to `http://127.0.0.1:5000/swagger-ui` and see your Swagger documentation rendered out! [^1]: [Flask Blueprint (Flask Official Documentation)](https://flask.palletsprojects.com/en/2.1.x/api/#flask.Blueprint) ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/item.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") def put(self, item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} def post(self): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): def get(self, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(self, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): def get(self): return {"stores": list(stores.values())} def post(self): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/start/app.py ================================================ import uuid from flask import Flask, request from flask_smorest import abort from db import stores, items app = Flask(__name__) @app.get("/item/") def get_item(item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") @app.delete("/item/") def delete_item(item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @app.put("/item/") def update_item(item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @app.get("/item") def get_all_items(): return {"items": list(items.values())} @app.post("/item") def create_item(): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item @app.get("/store/") def get_store(store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") @app.delete("/store/") def delete_store(store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @app.get("/store") def get_stores(): return {"stores": list(stores.values())} @app.post("/store") def create_store(): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/06_api_with_method_views/start/requirements.txt ================================================ flask python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/README.md ================================================ --- title: Adding marshmallow schemas description: A marshmallow schema is useful for validation and serialization. Learn how to write them in this lecture. ctslug: adding-marshmallow-schemas --- # Adding marshmallow schemas Something that we're lacking in our API at the moment is validation. We've done a _tiny_ bit of it with this kind of code: ```py if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) ``` But there's so much more we can do. For starters, some data points may be optional in some endpoints. We also want to check the data type is correct (i.e. `price` shouldn't be a string, for example). To do this kind of checking we can construct a massive `if` statement, or we can use a library that is made specifically for it. The `marshmallow`[^1] library is used to define _what_ data fields we want, and then we can pass incoming data through the validator. We can also go the other way round, and give it a Python object which `marshmallow` then turns into a dictionary. ## Writing the `ItemSchema` Here's the definition of an `Item` using `marshmallow` (this is called a **schema**): ```py title="schemas.py" from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) ``` A couple of weird things maybe! The `id` field is a string, but it has the `dump_only=True` argument. This means that when we use marshmallow to _validate incoming data_, the `id` field won't be used or expected. However, when we use marshmallow to _serialize_ data to be returned to a client, the `id` field will be included in the output. The other fields will be used for both validation and serialization, and since they have the `required=True` argument, that means that when we do validation if the fields are not present, an error will be raised. `marshmallow` will also check the data type with `fields.Float` and `fields.Int`. ## Writing the `ItemUpdateSchema` Something that even to do this day sits a bit weird with me is having multiple different schemas for different applications. When we want to update an Item, we have different requirements than when we want to create an item. The main difference is that the incoming data to our API when we update an item is different than when we create one. Fields are optional, such that not all item fields should be required. Also, you may not want to allow certain fields _at all_. This is the `ItemUpdateSchema`: ```py title="schemas.py" class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() ``` As you can see, these are not `required=True`. I've also taken off the `id` and `store_id` fields, because: - This schema will only be used for incoming data, and we will never receive an `id`. - We don't want clients to be able to change the `store_id` of an item. If you wanted to allow this, you can add the `store_id` field here as well. ## Writing the `StoreSchema` ```py title="schemas.py" class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ``` There's not much to explain here! Similar to the `ItemSchema`, we have `id` and `name` since those are the only fields we need for a store. ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/item.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") def put(self, item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} def post(self): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): def get(cls): return {"stores": list(stores.values())} def post(cls): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/end/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/requirements.txt ================================================ flask flask-smorest python-dotenv ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/item.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") def put(self, item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} def post(self): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/07_marshmallow_schemas/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): def get(cls): return {"stores": list(stores.values())} def post(cls): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/README.md ================================================ --- title: Validation with marshmallow description: We can use the marshmallow library to validate request data from our API clients. ctslug: validation-with-marshmallow --- # Validation with marshmallow Now that we've got our schemas written, let's use them to validate incoming data to our API. With Flask-Smorest, this couldn't be easier! Let's start with `resources/item.py` ## Validation in `resources/item.py` At the top of the file, import the schemas: ```py from schemas import ItemSchema, ItemUpdateSchema ``` We have two sets of data that may be incoming (in the JSON body of a request): new items and updating items. So let's go to the `ItemList#post` method and make a couple changes! First, let's get rid of the existing data validation. Delete the highlighted lines below: ```py def post(self): # highlight-start item_data = request.get_json() if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) # highlight-end for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ``` Now, I know what you're thinking! What about `item_data`? Do we not need to keep that? When we use `marshmallow` for validation with Flask-Smorest, it will inject the validated data into our method for us. Look at these two highlighted lines: ```py # highlight-start @blp.arguments(ItemSchema) def post(self, item_data): # highlight-end for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ``` Nice! Plus, doing this also adds to your Swagger UI documentation. Let's do the same when updating items: ```py # highlight-start @blp.arguments(ItemUpdateSchema) def put(self, item_data, item_id): # highlight-end try: item = items[item_id] item |= item_data return item except KeyError: abort(404, message="Item not found.") ``` :::caution Order of parameters Be careful here since we've now got `item_data` and `item_id`. The URL arguments come in at the end. The injected arguments are passed first, so `item_data` goes before `item_id` in our function signature. ::: ## Validation in `resources/store.py` Now let's do the same in `store.py`! At the top of the file, import the schema: ```py from schemas import StoreSchema ``` When creating a store, we'll have this: ```py @blp.arguments(StoreSchema) def post(cls, store_data): for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ``` ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/item.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from schemas import ItemSchema, ItemUpdateSchema from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @blp.arguments(ItemUpdateSchema) def put(self, item_data, item_id): try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} @blp.arguments(ItemSchema) def post(self, item_data): for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/resources/store.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): def get(cls): return {"stores": list(stores.values())} @blp.arguments(StoreSchema) def post(cls, store_data): for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/end/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/item.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") def put(self, item_id): item_data = request.get_json() # There's more validation to do here! # Like making sure price is a number, and also both items are optional # Difficult to do with an if statement... if "price" not in item_data or "name" not in item_data: abort( 400, message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.", ) try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} def post(self): item_data = request.get_json() # Here not only we need to validate data exists, # But also what type of data. Price should be a float, # for example. if ( "price" not in item_data or "store_id" not in item_data or "name" not in item_data ): abort( 400, message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.", ) for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): def get(cls): return {"stores": list(stores.values())} def post(cls): store_data = request.get_json() if "name" not in store_data: abort( 400, message="Bad request. Ensure 'name' is included in the JSON payload.", ) for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/08_validation_with_marshmallow/start/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/README.md ================================================ --- title: Decorating responses with Flask-Smorest description: Add response serialization and status code to API endpoints, and add to your documentation in the process. ctslug: decorating-responses-with-flask-smorest --- # Decorating responses with Flask-Smorest We can use marshmallow schemas for serialization when we respond to a client. To do so, we need to tell Flask-Smorest what Schema to use when responding. This will do a few things: 1. Update your documentation to show what data and status code will be returned by the endpoint. 2. Pass any data your endpoint returns through the marshmallow schema, casting data types and removing data that isn't in the schema. ## Decorating responses in `resources/item.py` Let's start with retrieving a specific item. Up until now, we've been doing this: ```py def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") ``` But now we can run the `items[item_id]` dictionary through the marshmallow schema and tell Flask-Smorest about it so the documentation will be updated: ```py @blp.response(200, ItemSchema) def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") ``` :::info The number, `200`, is the status code. It means "OK" (all good). ::: Our endpoint for updating items looks like this: ```py @blp.arguments(ItemUpdateSchema) def put(self, item_data, item_id): try: item = items[item_id] item |= item_data return item except KeyError: abort(404, message="Item not found.") ``` Let's pass this through the schema as well: ```py @blp.arguments(ItemUpdateSchema) # highlight-start @blp.response(200, ItemSchema) # highlight-end def put(self, item_data, item_id): try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") ``` :::caution Careful with the order of decorators in these functions! ::: When we get to returning a list of items, it looks like this: ```py # highlight-start @blp.response(200, ItemSchema(many=True)) # highlight-end def get(self): return items.values() ``` And finally, don't forget to decorate the new item endpoint too: ```py @blp.arguments(ItemSchema) # highlight-start @blp.response(201, ItemSchema) # highlight-end def post(self, item_data): for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ``` ## Decorating responses in `resources/store.py` Going a bit more quickly here since you already know what's going on with this decorator. The highlighted lines are new: ```py title="resources/store.py" import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): # highlight-start @blp.response(200, StoreSchema) # highlight-end def get(cls, store_id): try: return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): # highlight-start @blp.response(200, StoreSchema(many=True)) # highlight-end def get(cls): return stores.values() @blp.arguments(StoreSchema) # highlight-start @blp.response(201, StoreSchema) # highlight-end def post(cls, store_data): for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ``` ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/resources/item.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from schemas import ItemSchema, ItemUpdateSchema from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return items.values() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/resources/store.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(cls): return stores.values() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(cls, store_data): for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/end/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/resources/item.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from schemas import ItemSchema, ItemUpdateSchema from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @blp.arguments(ItemUpdateSchema) def put(self, item_data, item_id): try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): def get(self): return {"items": list(items.values())} @blp.arguments(ItemSchema) def post(self, item_data): for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/resources/store.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): def get(cls): return {"stores": list(stores.values())} @blp.arguments(StoreSchema) def post(cls, store_data): for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: docs/docs/05_flask_smorest/09_decorating_responses/start/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/05_flask_smorest/Insomnia_section5_Docker.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:36:20.139Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_08302ba35f784bdc9fa2edc0cb080287","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666985452213,"created":1666905719010,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_0bc4d91251f54e1d8e00966a259b35bc","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719008,"created":1666905719008,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_e6c8aab80c134d35810fd37d43cce51e","parentId":null,"modified":1666991880304,"created":1666905718998,"name":"Section 5 - Docker","description":"","scope":"collection","_type":"workspace"},{"_id":"req_6fdedbe47a9941af9b8459816f179274","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666985886605,"created":1666905719013,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0c240b23280746a6a1a56d7644fb89ce","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666987464108,"created":1666905719011,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fc255f6789fe45ed80b2ef83e6bb6645","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666985462540,"created":1666905719014,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b42e3c4d855a433394ac1a8a60c2b91b","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666985467338,"created":1666905719020,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_80dee5df10c347198d8f12d85703d582","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719016,"created":1666905719016,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_9a89b2ecfc61457d8cac15985597c0a0","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666986841489,"created":1666905719023,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_3d189bf5d88349e3bce363a420407f65","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666987468265,"created":1666905719018,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": \"8efca659f8674c56b5cd035ecc0d42ec\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_35d865c76bce4e1b9c378d82ece413f7","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666985474126,"created":1666905719019,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c2bf495d5cbb49d8b933b832a717662a","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666987071504,"created":1666905719022,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_adf22718b4e044e5b54b37c869463582","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666985430514,"created":1666905719000,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_210b7ba8709f44f29c305ed544da17c3","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719004,"created":1666905719004,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_7a427f233a494727845a45ba1325ea85","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719034,"created":1666905719007,"fileName":"Flask-Smorest-Docker","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/05_flask_smorest/Insomnia_section5_before_Docker.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:35:47.649Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_3d2b5cd58a4b4a6983c133118c5f8027","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666125193227,"created":1666124761134,"url":"http://127.0.0.1:5000/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_afac4dd2683746c586c6ff61228611de","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666125229064,"created":1666124761133,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","parentId":null,"modified":1666991873213,"created":1666124761123,"name":"Section 5 before Docker","description":"","scope":"collection","_type":"workspace"},{"_id":"req_bd3ecff11e5b49baa489812528235afb","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666902781180,"created":1666124761139,"url":"http://127.0.0.1:5000/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b9dafd45675e4c478fa4dd125f4827b3","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666902941803,"created":1666124761136,"url":"http://127.0.0.1:5000/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_34cbd59313d44bbfa4fd70166e341b05","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666902749338,"created":1666124977832,"url":"http://127.0.0.1:5000/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_637d0fb6ba9d4c25b6ad9f5bdda73036","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666902961406,"created":1666125038450,"url":"http://127.0.0.1:5000/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_91ec9103821245f69f82aa78362f81e1","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666125224286,"created":1666124761144,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_e581f2420345418c84d71dbed226b6da","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666125710431,"created":1666125184534,"url":"http://127.0.0.1:5000/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d48cf679c2664c9bb566b600634b966f","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666902939274,"created":1666124761145,"url":"http://127.0.0.1:5000/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": \"f48f94a4760e40d39debf155396a9dec\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8982d9bcce734f60a9f27a8eb1fc748c","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666125332019,"created":1666124928966,"url":"http://127.0.0.1:5000/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_99fbb0c34cd049f1bb8ac4e944f0ae6d","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666902838552,"created":1666125104208,"url":"http://127.0.0.1:5000/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_6b3e8bb38d0c4154826d63642b863687","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666124761125,"created":1666124761125,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_9b95c15dadb44c03bf60cc7386095847","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666124761128,"created":1666124761128,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_cfb94f75feff4930966c80f350b1e115","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666124761155,"created":1666124761131,"fileName":"Flask-Smorest","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/05_flask_smorest/_category_.json ================================================ { "label": "Flask-Smorest for More Efficient Development", "position": 5 } ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/01_project_overview_sqlalchemy/README.md ================================================ --- title: Project Overview, and why use SQLAlchemy description: Let's look at what we'll do in this section. There are no changes to the client-facing API at all, just changes internally to how we store data. ctslug: project-overview-why-use-sqlalchemy --- # Project Overview (and why use SQLAlchemy) :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! ::: In this section we'll make absolutely no changes to the API! However, we will completely change the way we store data. Up until now, we've been storing data in an "in-memory database": a couple of Python dictionaries. When we stop the app, the data is destroyed. This is obviously not great, so we want to move to a proper store that can keep data around between app restarts! We'll be using a relational database for data storage, and there are many different options: SQLite, MySQL, PostgreSQL, and others. At this point we have two options regarding how to interact with the database: 1. We can write SQL code and execute it ourselves. For example, when we want to add an item to the database we'd write something like `INSERT INTO items (name, price, store_id) VALUES ("Chair", 17.99, 1)`. 2. We can use an ORM, which can take Python objects and turn them into database rows. For this project, we are going to use an ORM because it makes the code much cleaner and simpler. Also, the ORM library (SQLAlchemy) helps us with many potential issues with using SQL, such as: - Multi-threading support - Handling creating the tables and defining the rows - Database migrations (with help of another library, Alembic) - Like mentioned, it makes the code cleaner, simpler, and shorter To get started, add the following to the `requirements.txt` file: ```text title="requirements.txt" sqlalchemy flask-sqlalchemy ```
What is Flask-SQLAlchemy?

SQLAlchemy is the ORM library, that helps map Python classes to database tables and columns, and turns Python objects of those classes into specific rows.

Flask-SQLAlchemy is a Flask extension which helps connect SQLAlchemy to Flask apps.

With this, install your requirements (remember to activate your virtual environment first!). ``` pip install -r requirements.txt ``` Let's begin creating our SQLAlchemy models in the next lecture. ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/README.md ================================================ --- title: Create a simple SQLAlchemy Model description: Lecture description goes here. ctslug: create-a-simple-sqlalchemy-model --- # Create a simple SQLAlchemy Model ## Initialize the SQLAlchemy instance ```python title="db.py" from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ``` ## Create models without relationships Every model inherits from `db.Model`. That way when we tell SQLAlchemy about them (in [Configure Flask-SQLAlchemy](../configure_flask_sqlalchemy))), it will know to look at them to create tables. Every model also has a few properties that let us interact with the database through the model, such as `query` (more on this in [Insert models in the database with SQLAlchemy](../insert_models_sqlalchemy)). ```python title="models/item.py" from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, unique=False, nullable=False) ``` ```python title="models/store.py" from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) ``` ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, unique=False, nullable=False) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/end/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Int(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/02_create_simple_sqlalchemy_model/start/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Str(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/README.md ================================================ --- title: One-to-many relationships with SQLAlchemy description: Model relationships let us easily retrieve information about a related model, without having to do SQL JOINs manually. ctslug: one-to-many-relationships-with-sqlalchemy --- # One-to-many relationships with SQLAlchemy ```python title="models/item.py" from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) # highlight-start store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") # highlight-end ``` ```python title="models/store.py" from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) # highlight-start items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") # highlight-end ``` To make it easier to import and use the models, I'll also create a `models/__init__.py` file that imports the models from their files: ```python title="models/__init__.py" from models.store import StoreModel from models.item import ItemModel ``` ## What is `lazy="dynamic"`? Without `lazy="dynamic"`, the `items` attribute of the `StoreModel` resolves to a list of `ItemModel` objects. With `lazy="dynamic"`, the `items` attribute resolves to a SQLAlchemy **query**, which has some benefits and drawbacks: - A key benefit is load speed. Because SQLAlchemy doesn't have to go to the `items` table and load items, stores will load faster. - A key drawback is accessing the `items` of a store isn't as easy. - However this has another hidden benefit, which is that when you _do_ load items, you can do things like filtering before loading. Here's how you could get all the items, giving you a list of `ItemModel` objects. Assume `store` is a `StoreModel` instance: ```python store.items.all() ``` And here's how you would do some filtering: ```python store.items.filter_by(name=="Chair").first() ``` ## Updating our marshmallow schemas Now that the models have these relationships, we can modify our marshmallow schemas so they will return some or all of the information about the related models. We do this with the `Nested` marshmallow field. :::caution Something to be careful about is having schema A which has a nested schema B, which has a nested schema A. This will lead to an infinite nesting, which is obviously never what you want! ::: To avoid infinite nesting, we are renaming our schemas which _don't_ use nested fields to `Plain`, such as `PlainItemSchema` and `PlainStoreSchema`. Then the schemas that _do_ use nesting can be called `ItemSchema` and `StoreSchema`, and they inherit from the plain schemas. This reduces duplication and prevents infinite nesting. ```python title="schemas.py" from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ``` ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, unique=False, nullable=False) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/03_one_to_many_relationships_sqlalchemy/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/README.md ================================================ --- title: Configure Flask-SQLAlchemy description: Link Flask-SQLAlchemy with our Flask app and create the initial tables. ctslug: configure-flask-sqlalchemy --- # Configure Flask-SQLAlchemy We want to add two imports to `app.py`: ```python title="app.py" from db import db import models ``` ## The Flask app factory pattern Up until now, we've been creating the `app` variable (which is the Flask app) directly in `app.py`. With the app factory pattern, we write a function that _returns_ `app`. That way we can _pass configuration values_ to the function, so that we configure the app before getting it back. This is especially useful for testing, but also if you want to do things like have staging and production apps. To do the app factory, all we do is place all the app-creation code inside a function which **must be called `create_app()`**. ```python title="app.py" from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint # highlight-start def create_app(): app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app # highlight-end ``` ## Add Flask-SQLAlchemy code to the app factory ```python title="app.py" import os from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint # highlight-start def create_app(db_url=None): # highlight-end app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" # highlight-start app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) # highlight-end api = Api(app) # highlight-start with app.app_context(): db.create_all() # highlight-end api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ``` We've done three things: 1. Added the `db_url` parameter. This lets us create an app with a certain database URL, or alternatively try to fetch the database URL from the environment variables. The default value will be a local SQLite file, if we don't pass a value ourselves and it isn't in the environment. 2. Added two SQLAlchemy values to `app.config`. One is the database URL (or URI), the other is a [configuration option](https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/) which improves performance. 3. When the app is created, tell SQLAlchemy to create all the database tables we need. :::tip How does SQLAlchemy know what tables to create? The line `import models` lets SQLAlchemy know what models exist in our application. Because they are `db.Model` instances, SQLAlchemy will look at their `__tablename__` and defined `db.Column` attributes to create the tables. ::: ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/04_configure_flask_sqlalchemy/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/README.md ================================================ --- title: Insert models in the database with SQLAlchemy description: Learn how to use SQLAlchemy to add new rows to our SQL database. ctslug: insert-models-in-database-with-sqlalchemy --- # Insert models in the database with SQLAlchemy Inserting models with SQLAlchemy couldn't be easier! We'll use the `db.session`[^1] variable to `.add()` a model. Let's begin working on our `Item` resource: ```python title="resources/item.py" from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel ... @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ``` Similarly in our `Store` resource: ```python title="resources/store.py" from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel ... @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ``` Note here we're catching two different errors, `IntegrityError` for when a client attempts to create a store with a name that already exists, and `SQLAlchemyError` for anything else. Since the `StoreModel`'s `name` column is marked as `unique=True`, then an `IntegrityError` is raised when we try to insert another row with the same name. [^1]: [Session Basics (SQLAlchemy Documentation)](https://docs.sqlalchemy.org/en/14/orm/session_basics.html) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): raise NotImplementedError("Creating an item is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): raise NotImplementedError("Creating a store is not implemented.") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/05_insert_models_sqlalchemy/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/README.md ================================================ --- title: Get models by ID from the database using SQLAlchemy description: Learn how to fetch a specific model using its primary key column, and how to return a 404 page if it isn't found. ctslug: get-models-by-id-from-the-database --- # Get models by ID from the database using SQLAlchemy Using the model class's `query` attribute, we have access to two very handy methods: - `ItemModel.query.get(item_id)` gives us an `ItemModel` object from the database where the `item_id` matches the primary key. - `ItemModel.query.get_or_404(item_id)` does the same, but makes Flask immediately return a "Not Found" message, together with a 404 error code, if no model can be found with that ID in the database. :::tip When we use `.get_or_404()` and nothing is found, this is the response from the API: ```json {"code": 404, "status": "Not Found"} ``` The status code of this response is also 404. ::: We're going to use `.get_or_404()` repeatedly in our resources! For now, and since we'll need an `ItemModel` instance in all our `Item` resource methods, let's add that: ```python title="resources/item.py" @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): # highlight-start item = ItemModel.query.get_or_404(item_id) return item # highlight-end def delete(self, item_id): # highlight-start item = ItemModel.query.get_or_404(item_id) # highlight-end raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): # highlight-start item = ItemModel.query.get_or_404(item_id) # highlight-end raise NotImplementedError("Updating an item is not implemented.") ``` Similarly in our `Store` resource: ```python title="resources/store.py" @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): # highlight-start store = StoreModel.query.get_or_404(store_id) return store # highlight-end def delete(self, store_id): # highlight-start store = StoreModel.query.get_or_404(store_id) # highlight-end raise NotImplementedError("Deleting a store is not implemented.") ``` With this, we're ready to continue! ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): raise NotImplementedError("Getting an item is not implemented.") def delete(self, item_id): raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): raise NotImplementedError("Getting a store is not implemented.") def delete(self, store_id): raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/06_get_models_or_404/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/README.md ================================================ --- title: Updating models with SQLAlchemy description: How to make changes to an existing model, or insert one if it doesn't already exist. ctslug: updating-models-with-sqlalchemy --- # Updating models with SQLAlchemy A frequent operation in REST APIs is the "upsert", or "update or insert". This is an idempotent operation where we send the data we want the API to store. If the data identifier already exists, an update is done. If it doesn't, it is created. This idempotency is frequently seen with `PUT` requests. You can see it in action here: ```python title="resources/item.py" @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): # highlight-start item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item # highlight-end ``` Our `ItemUpdateSchema` at the moment looks like this: ```python title="schemas.py" class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() ``` But since now our update endpoint may create items, we need to change the schema to optionally accept a `store_id`. When updating an item, `name` or `price` (or both) may be passed, but when creating an item, `name`, `price`, and `store_id` must be passed. Update the `ItemUpdateSchema` to this: ```python title="schemas.py" class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() ``` ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) raise NotImplementedError("Updating an item is not implemented.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/07_updating_models_sqlalchemy/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/README.md ================================================ --- title: Retrieve a list of all models description: Get more than one model and return it as a list from the API. ctslug: retrieve-a-list-of-all-models --- # Retrieve a list of all models Using the `query` attribute of our model class, we can retrieve all the results of the query: ```python title="resources/item.py" @blp.response(200, ItemSchema(many=True)) def get(self): # highlight-start return ItemModel.query.all() # highlight-end ``` ```python title="resources/store.py" @blp.response(200, StoreSchema(many=True)) def get(self): # highlight-start return StoreModel.query.all() # highlight-end ``` ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing items is not implemented.") @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema, ItemSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): raise NotImplementedError("Listing stores is not implemented.") @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/08_retrieve_list_all_models/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/README.md ================================================ --- title: Delete models with SQLAlchemy description: Use SQLAlchemy to handle removal of a specific model. ctslug: delete-models-with-sqlalchemy --- # Delete models with SQLAlchemy Just as with adding, deleting models is a matter of using `db.session`, and then committing when the deletion is complete: ```python title="resources/item.py" def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) # highlight-start db.session.delete(item) db.session.commit() return {"message": "Item deleted."} # highlight-end ``` ```python title="resources/store.py" def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) # highlight-start db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 # highlight-end ``` ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) raise NotImplementedError("Deleting an item is not implemented.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) raise NotImplementedError("Deleting a store is not implemented.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/09_delete_models_sqlalchemy/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/README.md ================================================ --- title: Delete models with relationships using cascades description: Tell SQLAlchemy what to do with related models when you delete the parent. ctslug: delete-models-with-relationships-using-cascades --- # Delete models with relationships using cascades When you delete a model that has a relationship to other models that still exist, the default behavior in SQLAlchemy with PostgreSQL is to raise an error. This is because SQLAlchemy does not want to allow you to accidentally delete data that is still being used by other models. Let's say you have a `Store 1` that has two items, `Item 1` and `Item 2`. If you try to delete Store 1 without first deleting Item 1 and Item 2, SQLAlchemy will raise an error because the items are still related to the store. This means the items have a **Foreign Key** that references the store you're trying to delete. If the store actually was deleted, the items have a store ID that references something that doesn't exist. To fix this, you can use a feature called "cascading deletes". Cascading deletes allow you to specify that when a model is deleted, any related models should also be deleted automatically. SQLAlchemy makes it easy to add cascades to our models, here's how you might do that! ```python title="models/store.py" from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) # highlight-start items = db.relationship("ItemModel", back_populates="store", lazy="dynamic", cascade="all, delete") # highlight-end ``` Remember that `StoreModel` and `ItemModel` have a one-to-many relationship, where each store can have multiple items, and each item belongs to a single store. The `cascade="all,delete"` argument in the `relationship()` call for the `StoreModel.items` attribute specifies that when a store is deleted, all of its related items should also be deleted. If you add a `cascade` on the relationship in the `ItemModel`, then when an item is deleted, its related store should also be deleted. This is not what we want, so we won't add a cascade to `ItemModel`. With this code in place, if you try to delete a store that still has items, the items will be deleted automatically along with the store. This will allow you to delete the store without having to delete the items individually. For more information, I strongly recommend reading [the official documentation](https://docs.sqlalchemy.org/en/20/orm/cascades.html#delete)! There are also other cascade options you can pass in depending on what you want to happen to related models when the parent changes or is deleted. ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship( "ItemModel", back_populates="store", lazy="dynamic", cascade="all, delete" ) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/requirements.txt ================================================ flask flask-sqlalchemy flask-smorest python-dotenv marshmallow ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/10_delete_related_models_sqlalchemy/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/11_conclusion/README.md ================================================ --- title: Conclusion of this section description: Review everything we've changed this section to add SQL storage with SQLAlchemy to our API. ctslug: conclusion-of-this-section --- # Conclusion of this section Adding SQL storage to our app has required quite a few changes! Let's do a quick review. ## Installed SQLAlchemy and Flask-SQLAlchemy ``` pip install sqlalchemy flask-sqlalchemy ``` And ```text title="requirements.txt" sqlalchemy flask-sqlalchemy ``` ## Created models ```python title="models/item.py" from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ``` And ```python title="models/store.py" from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ``` ## Updated resources to use SQLAlchemy Previously we were using Python dictionaries as a database. Now we've swapped them out for using SQLAlchemy models by: - Importing the models in our resource files - Retrieving models from the database with `ModelClass.query.get_or_404(model_id)`. - Updating models by changing attributes, or creating new model class instances, and then saving and committing with `db.session.add(model_instance)` and `db.session.commit()`. - Deleting models with `db.session.delete(model_instance)` followed by `db.session.commit()`. ## Updated marshmallow schemas Since now our models have relationships, that means that the schemas can have `Nested` fields. The schemas that don't have `Nested` fields we've called "Plain" schemas, and those that do are named after the model they represent. ```python title="schemas.py" from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ``` And that's it! Quite a few changes, but hopefully you're still with me. In the following sections we'll be adding more functionality to our API, so stay tuned! ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/Insomnia_section6.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:30:25.805Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_8612530e54144a039af84006ee8c882d","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666987689179,"created":1666987689179,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_7ed8d16fd87545519f2f64b2613ea84a","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689178,"created":1666987689178,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_a6cd641e98494bca9a11fe77b66c7e37","parentId":null,"modified":1666987689171,"created":1666987689171,"name":"Section 6","description":"","scope":"collection","_type":"workspace"},{"_id":"req_335002433e9745068d074f1f942ddde2","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666987689183,"created":1666987689183,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4f7b9d616b0e44ca94ca51cc71660da0","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666990320166,"created":1666987689181,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9228903cf7a54601a51a59f6a6692363","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666987689184,"created":1666987689184,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d1d499ead63e469ca04571899cc4759f","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689190,"created":1666987689190,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_8761c7b0aa5142cba8985868cbda3de2","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689186,"created":1666987689186,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_94738c7e8c774bd597ffe97bf7b921b6","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689194,"created":1666987689194,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0624b67ef6b841f482b7e7522fb6f405","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666990328367,"created":1666987689187,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b2651043ea5e4b33b073f260712fb114","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689189,"created":1666987689189,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_640f313dbd5a4bfcbf98081e2fab6d4a","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689192,"created":1666987689192,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_892efa21f8454221972d0c77a336872c","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689172,"created":1666987689172,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_aff586a35c4c49aa91c5defb067355bf","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689174,"created":1666987689174,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_4e7424f78749436bacdb44d3a1eba77a","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689205,"created":1666987689176,"fileName":"Section 6","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/06_sql_storage_sqlalchemy/_category_.json ================================================ { "label": "SQL Storage with Flask-SQLAlchemy", "position": 6 } ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/01_section_changes/README.md ================================================ --- title: Changes in this section description: In this section we add Tags to our Stores, and link these to Items using a many-to-many relationship. ctslug: changes-in-many-to-many-section --- # Changes in this section :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! ::: It's common for online stores to use "tags" to group items and to be able to search for them a bit more easily. For example, an item "Chair" could be tagged with "Furniture" and "Office". Another item, "Laptop", could be tagged with "Tech" and "Office". So one item can be associated with many tags, and one tag can be associated with many items. This is a many-to-many relationship, which is bit trickier to implement than the one-to-many we've already implemented between Items and Stores. ## When you have many stores We want to add one more constraint to tags, however. That is that if we have many stores, it's possible each store wants to use different tags. So the tags we create will be unique to each store. This means that tags will have: - A many-to-one relationship with stores - A many-to-many relationship with items Here's a diagram to illustrate what this looks like: ![ER database model showing relationships](https://res.cloudinary.com/teclado/image/upload/v1689180742/courses/rest-apis-flask-python/db_model.drawio_ilgupm.png) ## New API endpoints to be added In this section we will add all the Tag endpoints: | Method | Endpoint | Description | | -------- | --------------------- | ------------------------------------------------------- | | `GET` | `/store/{id}/tag` | Get a list of tags in a store. | | `POST` | `/store/{id}/tag` | Create a new tag. | | `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | | `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | | `GET` | `/tag/{id}` | Get information about a tag given its unique id. | | `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/README.md ================================================ --- title: One-to-many relationships review description: A super-quick look at creating the Tag model and setting up the one-to-many relationship with Stores. ctslug: one-to-many-relationship-between-tag-store --- # One-to-many relationship between Tag and Store Since we've already learned how to set up one-to-many relationships with SQLAlchemy when we looked at Items and Stores, let's go quickly in this section. ## The SQLAlchemy models import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
```python title="models/tag.py" from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") ``` ```python title="models/store.py" from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") # highlight-start tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") # highlight-end ```
Remember to import the `TagModel` in `models/__init__.py` so that it is then imported by `app.py`. Otherwise SQLAlchemy won't know about it, and it won't be able to create the tables. ## The marshmallow schemas These are the new schemas we'll add. Note that none of the tag schemas have any notion of "items". We'll add those to the schemas when we construct the many-to-many relationship. In the `StoreSchema` we add a new list field for the nested `PlainTagSchema`, just as it has with `PlainItemSchema`. ```python title="schemas.py" class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) # highlight-start tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) # highlight-end class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) ``` ## The API endpoints Let's add the Tag endpoints that aren't related to Items: | Method | Endpoint | Description | | ---------- | --------------------- | ------------------------------------------------------- | | ✅ `GET` | `/store/{id}/tag` | Get a list of tags in a store. | | ✅ `POST` | `/store/{id}/tag` | Create a new tag. | | ❌ `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | | ❌ `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | | ✅ `GET` | `/tag/{id}` | Get information about a tag given its unique id. | | ❌ `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | Here's the code we need to write to add these endpoints: ```python title="resources/tag.py" from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel from schemas import TagSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag ``` ## Register the Tag blueprint in `app.py` Finally, we need to remember to import the blueprint and register it! ```python title="app.py" from flask import Flask from flask_smorest import Api import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint # highlight-start from resources.tag import blp as TagBlueprint # highlight-end def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) # highlight-start api.register_blueprint(TagBlueprint) # highlight-end return app ``` ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/app.py ================================================ from flask import Flask from flask_smorest import Api import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel from schemas import TagSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/app.py ================================================ from flask import Flask from flask_smorest import Api import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/02_one_to_many_review/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/README.md ================================================ --- title: Many-to-many relationships description: Learn to set up a many-to-many relationship between two models using SQLAlchemy. ctslug: many-to-many-relationships --- # Many-to-many relationships ## The SQLAlchemy models In one-to-many relationships, one of the models has a foreign key that links it to another model. However, for a many-to-many relationship, one model can't have a single value as a foreign key (otherwise it would be a one-to-many!). Instead, what we do is construct a **secondary table** that has, in each row, a tag ID and and item ID. | id | tag_id | item_id | | --- | ------ | ------- | | 1 | 2 | 5 | | 2 | 1 | 4 | | 3 | 4 | 5 | | 4 | 1 | 3 |
Explanation of the table above

The table above has 4 rows, which tell us the following:

  1. Tag with ID 1 is linked to Items with IDs 3 and 4.
  2. Tag with ID 2 is linked to Item with ID 5.
  3. Tag with ID 4 is linked to Item with ID 5.

And therefore:

  1. Item with ID 3 is linked to Tag with ID 1.
  2. Item with ID 4 is linked to Tag with ID 1.
  3. Item with ID 5 is linked to Tags with IDs 2 and 4.

This is how many-to-many relationships work, and through this secondary table, the Tag.items and Item.tags attributes will be populated by SQLAlchemy.

The rows in this table then signify a link between a specific tag and a specific item, but without the need for those values to be stored in the tag or item models themselves. ### Writing the secondary table for many-to-many relationships As we've just seen, many-to-many relationships use a secondary table which stores which models of one side are related to which models of the other side. Just as we did with `Item`, `Store`, and `Tag`, we'll create a model for this secondary table: ```python title="models/item_tags.py" from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ``` Let's also add this to our `models/__init__.py` file: ```python title="models/__init__.py" from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ``` ### Using the secondary table in the main models import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
```python title="models/tag.py" from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") # highlight-start items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") # highlight-end ``` ```python title="models/item.py" from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") # highlight-start tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") # highlight-end ```
## The marshmallow schemas Next up, let's add the nested fields to the marshmallow schemas. The `TagAndItemSchema` will be used to return information about both the Item and Tag that have been modified in an endpoint, together with an informative message. ```python title="schemas.py" class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) # highlight-start tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) # highlight-end class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) # highlight-start items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) # highlight-end store = fields.Nested(PlainStoreSchema(), dump_only=True) # highlight-start class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) # highlight-end ``` ## The API endpoints Now let's add the rest of our API endpoints (grayed out are the ones we implemented in [one-to-many relationships review](../one_to_many_review/))! | Method | Endpoint | Description | | ---------------------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------- | | ✅ `GET` | `/store/{id}/tag` | Get a list of tags in a store. | | ✅ `POST` | `/store/{id}/tag` | Create a new tag. | | ✅ `POST` | `/item/{id}/tag/{id}` | Link an item in a store with a tag from the same store. | | ✅ `DELETE` | `/item/{id}/tag/{id}` | Unlink a tag from an item. | | ✅ `GET` | `/tag/{id}` | Get information about a tag given its unique id. | | ✅ `DELETE` | `/tag/{id}` | Delete a tag, which must have no associated items. | Here's the code (new lines highlighted): ```python title="resources/tag.py" from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db # highlight-start from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema # highlight-end blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag # highlight-start @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} # highlight-end @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag # highlight-start @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) # highlight-end ``` And with that, we're done! ## Making sure Store ID matches when linking tags If you wanted to, you can make sure that you can only link a tag that belongs to a certain store, with an item of that same store. Something like this would work: ```py if item.store.id != tag.store.id: abort(400, message="Make sure item and tag belong to the same store before linking.") ``` Now we're ready to look at securing API endpoints with user authentication. ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/app.py ================================================ from flask import Flask from flask_smorest import Api import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/app.py ================================================ from flask import Flask from flask_smorest import Api import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel from schemas import TagSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/03_many_to_many_relationships/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/Insomnia_section7.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:30:50.558Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_379d0e42420f466bbad1b7481e5e7816","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991794866,"created":1666990973919,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_86b5e8072a894c409febe46716e99809","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990939045,"created":1666990939045,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"wrk_6efa5c8b8fa142a28f436b209fba66fa","parentId":null,"modified":1666990745588,"created":1666990745588,"name":"Section 7","description":"","scope":"collection","_type":"workspace"},{"_id":"req_85adfd198935497bb7aedb266beb5bf3","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991788350,"created":1666990945502,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4765f7ca8e1e46308cdde255d09a2ffc","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991810641,"created":1666991378432,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f07aab6ead044ca7bba0de3437ab08c4","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991779049,"created":1666991031108,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_77d1a5f225c54acbb27bac15010722ad","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991824192,"created":1666991489163,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d60510ab22b2499abb20a63629e30fcd","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991828682,"created":1666991524256,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ab3c728a796e4b4ca51803248e1b0650","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666990745596,"created":1666990745596,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_597937a09435404ebe2200cbaeed101d","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745596,"created":1666990745596,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"req_a9d43bb23e1246da94aec50b9b9ca652","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666990745601,"created":1666990745601,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8a36225a08bb4dfbbf98fd983b0d4a5f","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666991654175,"created":1666990745599,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_303507538c0f408eb6d91784b7ed8d36","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666990745602,"created":1666990745602,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f4776751aecc4c6eafb264dc2d2c24cb","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745609,"created":1666990745609,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_baa111a1ff5849b4838637f09844bfde","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745604,"created":1666990745604,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_e6bc2422c8cf4f119c7dc10251a9af65","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745611,"created":1666990745611,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6c72c92f81924ce7bc26ceb488fd64ff","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666991658886,"created":1666990745605,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e86e0877045640d690454a99b176f3a2","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745607,"created":1666990745607,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f0c4a3d747a543249131e19ceea79e56","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745610,"created":1666990745610,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_7609e8f1315a4d77af52a6ba50f48205","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745590,"created":1666990745590,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_ce9759718e054191a685cec521ed7afc","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745592,"created":1666990745592,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_0f62897a05a449f9845b4c71eeb892b3","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745620,"created":1666990745594,"fileName":"Section 7","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/07_sqlalchemy_many_to_many/_category_.json ================================================ { "label": "Many-to-many relationships with SQLAlchemy", "position": 7 } ================================================ FILE: docs/docs/08_flask_jwt_extended/01_section_changes/README.md ================================================ --- title: Changes in this section description: Overview of the API endpoints we'll use for user registration and authentication. ctslug: changes-in-jwt-extended-section --- # Changes in this section :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! ::: In this section we will add the following endpoints: | Method | Endpoint | Description | | -------------- | ----------------- | ----------------------------------------------------- | | `POST` | `/register` | Create user accounts given an `email` and `password`. | | `POST` | `/login` | Get a JWT given an `email` and `password`. | | 🔒
`POST` | `/logout` | Revoke a JWT. | | 🔒
`POST` | `/refresh` | Get a fresh JWT given a refresh JWT. | | `GET` | `/user/{user_id}` | (dev-only) Get info about a user given their ID. | | `DELETE` | `/user/{user_id}` | (dev-only) Delete a user given their ID. | We will also protect some existing endpoints by requiring a JWT from clients. You can see which endpoints will be protected in [The API we'll build in this course](/docs/course_intro/what_is_rest_api/#the-api-well-build-in-this-course) ================================================ FILE: docs/docs/08_flask_jwt_extended/02_what_is_a_jwt/README.md ================================================ --- title: What is a JWT? description: Understand what a JWT is, what data it contains, and how it may be used. ctslug: what-is-a-jwt --- # What is a JWT? A JWT is a signed JSON object with a specific structure. Our Flask app will sign the JWTs with the secret key, proving that _it generated them_. The Flask app generates a JWT when a user logs in (with their username and password). In the JWT, we'll store the user ID. The client then stores the JWT and sends it to us on every request. Because we can prove our app generated the JWT (through its signature), and we will receive the JWT with the user ID in every request, we can _treat requests that include a JWT as "logged in"_. For example, if we want certain endpoints to only be accessible to logged-in users, all we do is require a JWT in them. Since the client can only get a JWT after logging in, we know that including a JWT is proof that the client logged in successfully at some point in the past. And since the JWT includes the user ID inside it, when we receive a JWT we know _who logged in_ to get the JWT. There's a lot more information about JWTs here: [https://jwt.io/introduction](https://jwt.io/introduction). This includes information such as: - What is stored inside a JWT? - Are JWTs secure? ================================================ FILE: docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/README.md ================================================ --- title: How are JWTs used? description: Learn who uses JWTs and how they are used by clients and servers to perform authentication. ctslug: how-are-jwts-used --- # How are JWTs used? :::info JWT vs. Access token? An "access token" is any piece of information that a client can use to authenticate. In this API, we use JWTs. Therefore you can say that the JWT and the access token are one and the same! ::: We've learned that a JWT is generated by the API and sent to the client. When the client wants to login they will send the API information that allows them to do so: usually, the user's username and password. The API then validates that this login information is correct, and generates the access token. Inside the access token, the API stores identifying information for the user. Then the access token is sent to the client who stores it in whichever way they see fit. In every subsequent request to the API, the client should include the access token. That way, just with that information, the API can tell _who_ made the request. The API can decode the access token and see inside it the identifying information for the user for whom the access token was generated. Here is a diagram of the interaction between client and API to generate an access token:
![Diagram showing the flow between client and server to generate an access token](https://res.cloudinary.com/teclado/image/upload/v1689180750/courses/rest-apis-flask-python/access-token-flow.drawio_gdr9oo.png)
## An example of using access tokens For example, let's say you want to make an API that has an endpoint `/my-info`. This endpoint should send the client information about the currently logged-in user. Let's imagine that **the client** is a website. In the website, there is a button, "See my info", which when clicked sends a request to the API's `/my-info` endpoint to get the logged-in user's information. ### Clicking the button without logging in If the user navigates to the website and clicks the "See my info" button, the website will send a request to the API. Because the user hasn't logged in yet, the website doesn't have an access token generated for this user. Therefore, the API responds with an "authentication error". The website receives the authentication error and that tells it that the user hasn't logged in. So the website can show the user a log-in form, for the user to enter their username and password. When the user enters their username and password, the website will send a request to the API's `/login` endpoint. The API then responds with the access token. The website stores the access token for use later. If the user clicks the "See my info" button again, now the website will include the access token in the request. The server will then: 1. See the access token. 2. Decode it. 3. Look at what user the access token was generated for. 4. Load _that_ user's information from the database. 5. Respond with that user's information. The website receives the user's information, and can display it. This is why the user sees their own information, and not someone else's. The access token was generated after they logged in with their details, and the access token stores their user ID. The server will use that to retrieve the correct data. Here is a rather long diagram depicting what happens:
![Diagram showing flow of data when user wants to load their information but aren't logged in](https://res.cloudinary.com/teclado/image/upload/v1689180750/courses/rest-apis-flask-python/my-info-flow.drawio_pzfjh7.png)
:::warning This course deals only with the API Remember that in this course, we're making the API. We are not concerned with the client! We don't care how the client stores the access token or even whether the client is a website, mobile app, Postman or Insomnia, or anything else! ::: ## When do users provide their username and password? Access tokens don't last forever: they normally have expiry times within 30 days of being generated. The shorter the expiry time of an access token, the more often that the user has to re-authenticate by providing their username and password, but the more secure the token is. Tokens are more secure if they expire sooner because if the user forgets to log out of a shared device, and someone else tries to use their account, the token will expire and they will be unable to use the account. Obviously, it's not a great experience for users if they have to keep re-entering their username and password constantly. Towards the end of this section we will learn about [token refreshing](../12_token_refreshing_flask_jwt_extended/README.md), which is a way to reduce the amount of times users have to re-authenticate, without affecting security too much. ================================================ FILE: docs/docs/08_flask_jwt_extended/03_how_is_jwt_used/how-are-jwts-used.key ================================================ [File too large to display: 17.2 MB] ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/README.md ================================================ --- title: Flask-JWT-Extended setup description: Install and set up the Flask-JWT-Extended extension with our REST API. ctslug: flask-jwt-extended-setup --- # Flask-JWT-Extended setup First, let's update our requirements: ```diff title="requirements.txt" + flask-jwt-extended ``` Then we must do two things: - Add the extension to our `app.py`. - Set a secret key that the extension will use to _sign_ the JWTs. ```python title="app.py" from flask import Flask from flask_smorest import Api # highlight-start from flask_jwt_extended import JWTManager # highlight-end from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) # highlight-start app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # highlight-end with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ``` :::caution The secret key set here, `"jose"`, is **not very safe**. Instead you should generate a long and random secret key using something like `str(secrets.SystemRandom().getrandbits(128))`. ::: ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship( "ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship( "ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/04_flask_jwt_extended_setup/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/README.md ================================================ --- title: The User model and schema description: Create the SQLAlchemy User model and marshmallow schema. ctslug: the-user-model-and-schema --- # The User model and schema Just as we did with items, stores, and tags, let's create two classes for our users: - The SQLAlchemy model, to interact with the database. - The marshmallow schema, to deserialize data from clients and serialize it back to return data. ## The User SQLAlchemy model ```python title="models/user.py" from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ``` Let's also add this class to `models/__init__.py` so it can then be imported by `app.py`: ```python title="models/__init__.py" from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ``` ## The User marshmallow schema ```python title="schemas.py" class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ``` ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship( "ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/05_user_model_and_schema/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/README.md ================================================ --- title: How to add a register endpoint to the REST API description: Learn how to add a registration endpoint to a REST API using Flask-Smorest and Flask-JWT-Extended. ctslug: how-to-add-a-register-endpoint-to-the-rest-api --- # How to add a register endpoint to the REST API Registering users sounds like a conceptually very difficult thing, but let's break it down into steps: - Receive username and password from the client (as JSON). - Check if a user with that username already exists. - If it doesn't... - Encrypt the password. - Add a new `UserModel` to the database. - Return a success message. ## Boilerplate set-up for a blueprint with Flask-Smorest First, we need our imports and blueprint set-up. This is the same for pretty much every Flask-Smorest blueprint, so you already know how to do it! ```python title="resources/user.py" from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") ``` ## Creating the `UserRegister` resource Now let's create the `MethodView` class, and register a route to it using the blueprint: ```python title="resources/user.py" from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") # highlight-start @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 # highlight-end ``` ## Creating a testing-only `User` resource Let's also create a `User` resource that we will only use during testing. It allows us to retrieve information about a single user, or delete a user. This will be handy so that using Insomnia or Postman we can clear the registered users and we don't have to change our request arguments each time! ```python title="resources/user.py" @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ``` ## Register the user blueprint in `app.py` Finally, let's go to `app.py` and register the blueprint! ```diff title="app.py" +from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint ... +api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) ``` ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/06_registering_users_rest_api/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/README.md ================================================ --- title: How to add a login endpoint to the REST API description: Learn how to add a login endpoint that returns a JWT to a REST API using Flask-Smorest and Flask-JWT-Extended. ctslug: how-to-add-a-login-endpoint --- # How to add a login endpoint to the REST API Now that we've done registration, we can do log in! It's very similar. Let's import `flask_jwt_extended.create_access_token` so that when we receive a valid username and password from the client, we can create a JWT and send it back: ```diff title="resources/user.py" from flask.views import MethodView from flask_smorest import Blueprint, abort +from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 ``` Then let's create our `UserLogin` resource. ```python title="resources/user.py" @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") ``` Here you can see the when we call `create_access_token(identity=str(user.id))` we pass in the user's `id`. This is what gets stored (among other things) inside the JWT, so when the client sends the JWT back on every request, we can tell who the JWT belongs to. **Update Nov 2024**: Before now, we used `identity=user.id`, but now we have to convert it to a string first. ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/07_login_users_rest_api/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/README.md ================================================ --- title: Protect endpoints by requiring a JWT description: Use jwt_required from Flask-JWT-Extended to prevent unauthorised users from making requests to certain endpoints in a REST API. ctslug: protect-endpoints-by-requiring-a-jwt --- # Protect endpoints by requiring a JWT Now that our users can sign up and log in, that means we can start _requiring login_ for certain endpoints. All this means in practice is that the client making the request must send a valid JWT. Remember, we can tell if a JWT is valid because it is _signed by our app_. If the user changes the JWT at all, the signature will be invalid, and we'll know it has been tampered with. Flask-JWT-Extended takes care of all that for us. ## Protecting routes in the `Item` resource ```python title="resources/item.py" from flask.views import MethodView from flask_smorest import Blueprint, abort # highlight-start from flask_jwt_extended import jwt_required # highlight-end from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): # highlight-start @jwt_required() # highlight-end @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item # highlight-start @jwt_required() # highlight-end def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get_or_404(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(**item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): # highlight-start @jwt_required() # highlight-end @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() # highlight-start @jwt_required() # highlight-end @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ``` ## Error handling with Flask-JWT-Extended There are many things that could go wrong with JWTs: - The JWT may be expired (they don't last forever!) - The JWT may be invalid, such as if the client makes changes to it - A JWT may be required, but none was provided - There's more (we'll look at them in coming lectures!) Let's go to `app.py` and add some configuration to tell Flask-JWT-Extended what to do in each of these cases. At the top, let's import `jsonify`: ```python title="app.py" from flask import Flask, jsonify ``` Then, after we define the `jwt = JWTManager(app)` variable, we can write some functions, each of which can run in different problem scenarios. ```python title="app.py" ... app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) ... ``` :::tip Note that some Flask-JWT-Extended error functions take two arguments: `jwt_header` and `jwt_payload`. Others take a single argument, `error`. The ones that don't take JWT information are those that would be called when a JWT is not present (above, when the JWT is invalid or required but not received). ::: ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship( "ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/08_protect_resources_with_jwt_required/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/README.md ================================================ --- title: JWT claims and authorization description: Learn how to add claims (extra info) to a JWT and use it for authorization in endpoints of a REST API. ctslug: jwt-claims-and-authorization --- # JWT Claims and Authorization JWT claims are extra data we can add to the JWT. For example, we could store in the JWT whether the user whose ID is stored in the JWT is an "administrator" or not. By doing this, we only have to check the user's permissions once, when we create the JWT, and not every time the user makes a request. To add a custom claim to a JWT we define a function similar to the error handling functions we wrote in the last lecture: ```python title="app.py" app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # highlight-start @jwt.additional_claims_loader def add_claims_to_jwt(identity): if identity == 1: return {"is_admin": True} return {"is_admin": False} # highlight-end @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) ``` :::caution Read from a database or config file Here we're assuming that the user with and ID of `1` will be the administrator. Normally you'd read this from either a config file or the database. ::: ## How to use JWT claims in an endpoint Let's make a small change to the `Item` resource so that only admins can delete items. To do so, we'll need to add an import for `get_jwt`: ```python title="resources/item.py" from flask_jwt_extended import jwt_required, get_jwt ``` Then in the `delete` endpoint, we can use `get_jwt()` to check the data in the JWT (which behaves like a dictionary): ```python title="resources/item.py" @jwt_required() def delete(self, item_id): # highlight-start jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") # highlight-end item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} ``` ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token @pytest.fixture() def admin_jwt(app): with app.app_context(): access_token = create_access_token( identity=1, additional_claims={"is_admin": True} ) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, admin_jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {admin_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_delete_item_without_admin(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 401 assert response.json["message"] == "Admin privilege required." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/09_jwt_claims_and_authorization/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/README.md ================================================ --- title: How to add logout to the REST API description: Create a logout endpoint that blocks certain JWTs from making further authenticated requests. ctslug: how-to-add-logout-to-the-rest-api --- # How to add logout to the REST API To log an user out we must _revoke_ their JWT. That way, if they send us the same JWT again, we can check whether it's been revoked or not. If it has, then we won't authorize them. To do this, we need a central store of revoked JWTs that we keep around at least until the revoked JWT has expired. Let's create our central revoked JWT storage in a file called `blocklist.py`. You could store this in the database instead, if you prefer. I'll leave that as an exercise for you. ```python title="blocklist.py" """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ``` ## Flask-JWT-Extended blocklist configuration for user logout Now, in `app.py`, let's add some more Flask-JWT-Extended configuration to do two things: - Check whether any JWT received is in the blocklist. - If they are, return an error message to that effect. ```python title="app.py" from blocklist import BLOCKLIST ... @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) ``` ## How to perform logout (i.e. add JWTs to the blocklist) Finally we need a resource in `resources/user.py` to actually add the user's JWT to the blocklist when they log out. ```python title="resources/user.py" from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, # highlight-start get_jwt, jwt_required, # highlight-end ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema # highlight-start from blocklist import BLOCKLIST # highlight-end blp = Blueprint("Users", "users", description="Operations on users") # highlight-start @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 # highlight-end # Other User routes here ``` ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token @pytest.fixture() def admin_jwt(app): with app.app_context(): access_token = create_access_token( identity=1, additional_claims={"is_admin": True} ) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, admin_jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {admin_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_delete_item_without_admin(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 401 assert response.json["message"] == "Admin privilege required." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_logout_user(client, created_user_jwt): response = client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Successfully logged out" def test_logout_user_twice(client, created_user_jwt): client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwt}"}, ) response = client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwt}"}, ) assert response.status_code == 401 assert response.json == { "description": "The token has been revoked.", "error": "token_revoked", } def test_logout_user_no_token(client): response = client.post( "/logout", ) assert response.status_code == 401 assert response.json["description"] == "Request does not contain an access token." def test_logout_user_invalid_token(client): response = client.post( "/logout", headers={"Authorization": "Bearer bad_token"}, ) assert response.status_code == 401 assert response.json == { "error": "invalid_token", "message": "Signature verification failed.", } def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token @pytest.fixture() def admin_jwt(app): with app.app_context(): access_token = create_access_token( identity=1, additional_claims={"is_admin": True} ) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, admin_jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {admin_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_delete_item_without_admin(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 401 assert response.json["message"] == "Admin privilege required." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import create_access_token from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/10_logout_users_rest_api/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/11_insomnia_request_chaining/README.md ================================================ --- title: Insomnia request chaining description: "Learn how to use Insomnia's Request Chaining to simplify our workflow and not have to copy-paste the access token in every request." ctslug: request-chaining-with-insomnia --- # Request chaining with Insomnia Up until now, we've been able to log in, get an access token, and then use that acces token to access the protected endpoints in our API. However, this workflow is a bit tedious, and we have to copy-paste the access token into every request. Luckily, Insomnia has a feature called [request chaining](https://support.insomnia.rest/article/26-request-chaining) that allows us to simplify this workflow! ## How to use request chaining with Insomnia Instead of passing the JWT in every request, you can access the `Headers` section and follow these steps. In the `Authorization` field, type `Bearer` add a space and then press `CTRL + SPACE` to get a contextual menu. Navigate to the `Response -> Body Attribute` field and select it, as shown in the screenshot below: ![Contextual menu with Generator Tag options](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/bearer_token_qk2jqi.png) After you've selected it, you'll see an error that looks like this: ![Response error in the Authorization field](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/error_darkak.png) If you click on the error, you'll see a modal window: ![Modal window with empty fields](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/modal_xutepw.png) Inside of this window, you will first need to select a request that you want to get the data from. Click on `Select item` inside the `Request` field and choose the `POST /login` endpoint. After you've done that, go to the `Filter` field. In this field, we are telling Insomnia which part of the response we want to get from it. The prompt is started with the dollar sign `$` and then we can use the dot to access the attributes of the response. If we only type `$` or `$.`, we'll get a list of all the attributes of the response: ![Attributes recieved from request using a dollar sign](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/attributes_kxngsi.png) In our case, we want to get the `access_token` attribute from the response, so we type `$.access_token`. If there was something else inside the `access_token` that you wanted to access like some value for example, you could do `$.access_token.value` and so on. After you've added the filter, you can set the `Trigger Behavior`. For example, you can set it to `When expired` and set time to 300 seconds. This means that the token will be refreshed every 5 minutes. The filled out window should look like this: ![Filled out modal window with attributes recieved from request using a dollar sign followed by .access_token](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/filled_out_muzo2u.png) You can now press `Done` and you'll see that the error is gone and that the `Authorization` field is filled out with the `Bearer` token: ![Filled out Authorization field with Bearer token](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/token_txc8sb.png) Since we use multiple protected endpoints, we would need to use the same method to get the `access_token` for every request. To tackle this problem, we can use [environment variables](https://support.insomnia.rest/article/13-environment-variables), as we did before for the `url` variable. To do this, you need to create a new environment variable. Click on the `No Environment` button in the top left corner of the Insomnia window and then click on the `Manage Environments` button, you will see a base environment that looks like this: ![Base environment with url environment variable only](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/base_environment_cfyrab.png) Add a new environment variable called `access_token`, and for its value you should follow the same process from above. As a quick reminder, you should press `CTRL + SPACE` to get the contextual menu and then select the `Response -> Body Attribute` field. Click on the error that will show up and fill out the modal window as we've done before. Make sure to wrap the `Response -> Body Attribute` with quotation marks, as shown in the screenshot. When the variable is created, your environment should look like this: ![Base environment with added access_token variable](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/environment_n8jfm9.png) You will now be able to use this environment variable in all of the endpoints by simply writing `{{access_token}}` in the field that you want to use it in. Coming back to the first example, you can use the access token in the `Authorization` field by writing `Bearer {{access_token}}` and this will be the result: ![Bearer with access_token environment variable](https://res.cloudinary.com/teclado/image/upload/v1689180763/courses/rest-apis-flask-python/result_krbno3.png) If you try and make the request, you will see that it works just like before, without needing to copy and paste the access token. ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/README.md ================================================ --- title: Token refreshing with Flask-JWT-Extended description: Learn about fresh and non-fresh tokens, as well as how to use a refresh token to generate a new, non-fresh access token. ctslug: token-refreshing-with-flask-jwt-extended --- # Token refreshing with Flask-JWT-Extended One of the problems with JWT authentication is that JWTs expire, and then the user has to re-authenticate by providing their username and password. How long to set the JWT expiry time is tricky. If it's very long, it's more likely that a different person may use the same device to access the website, and the previous user's account will still be logged in. If it's very short though, it's really annoying for users. This is where the concept of **token refreshing** comes into play. We can provide our users two tokens: an **access token** that they can use to, well, access endpoints, and a **refresh token** that they can use to get a new access token without having to provide their username and password. :::tip When do clients use the refresh token? When a client makes a request and sends the access token, if the token has expired our API sends back a message to that effect. At that point, the client can then, behind the scenes and without the user noticing, use the refresh to get a new access token, and re-request the original page. For a client, the authentication flow is a three-step process: 1. Send the access token they've got stored (may or may not be fresh). 2. If API responds with a 401 Unauthorized, use the refresh token to get a new access token and try again. Now you've got a new, non-fresh access token. 3. If the API responds with another 401 Unauthorized, ask the user to log in again. Now you've got a fresh access token. ::: The important thing here is **token freshness**. - A **fresh access token** is given to users immediately after logging in. - A **non-fresh access token** is given to users when they use their refresh token. This is important, because it means that we can protect certain routes by requiring a fresh access token. Since these tokens are only generated in response to login, we know that the user is probably who they say they are, and they haven't simply forgotten to log out. As an example, if the user goes to their "delete my account" page, we might want a fresh token to access that endpoint. However, if they're simply going to their profile page, we may accept a non-fresh token. ## How to create refresh tokens with Flask-JWT-Extended When a user logs in, we can create the access token and the refresh token at the same time. We will also make sure that the access token is marked as **fresh**. First, let's add new imports: ```diff title="resources/user.py" from flask_jwt_extended import ( create_access_token, + create_refresh_token, + get_jwt_identity, get_jwt, jwt_required, ) ``` Then let's change our `UserLogin` route: ```python title="resources/user.py" @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): # highlight-start access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 # highlight-end abort(401, message="Invalid credentials.") ``` **Update Nov 2024**: Before now, we used `identity=user.id`, but now we have to convert it to a string first. ## Writing the token refresh endpoint When a user logs in, they will now have the access token and the refresh token. Let's code another endpoint that will take the refresh token and return a new, non-fresh access token: ```python title="resources/user.py" @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ``` **Note**: here we don't need to call `str(current_user)` because `get_jwt_identity()` returns what we previously stored, which is a string. Note that above, we've told Flask-JWT-Extended that a refresh token is required with `@jwt_required(refresh=True)`. We'll do something similar for requiring fresh tokens in a second! ## Requiring a fresh token for certain endpoints Let's go to the create item endpoint and mark it as needing a fresh token. Normally, fresh tokens would be required for destructive operations such as changing passwords or deleting accounts. ```python title="resources/item.py" # highlight-start @jwt_required(fresh=True) # highlight-end @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ``` ## Error handling when a fresh token is required When a fresh token is required but a non-fresh token is provided, we want the Flask app to return a message to that effect. We can do this just as we did with the other Flask-JWT-Extended configurations: ```python title="app.py" @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) ``` ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def fresh_jwt(app): with app.app_context(): access_token = create_access_token(identity=1, fresh=True) return access_token @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token @pytest.fixture() def admin_jwt(app): with app.app_context(): access_token = create_access_token( identity=1, additional_claims={"is_admin": True} ) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, fresh_jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, fresh_jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, fresh_jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, fresh_jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_create_item_with_non_fresh_jwt(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 401 assert response.json == { "description": "The token is not fresh.", "error": "fresh_token_required", } def test_delete_item(client, admin_jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {admin_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_delete_item_without_admin(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 401 assert response.json["message"] == "Admin privilege required." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, fresh_jwt, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, fresh_jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, fresh_jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, fresh_jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {fresh_jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwts(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"], response.json["refresh_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_logout_user(client, created_user_jwts): response = client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwts[0]}"}, ) assert response.status_code == 200 assert response.json["message"] == "Successfully logged out" def test_logout_user_twice(client, created_user_jwts): client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwts[0]}"}, ) response = client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwts[0]}"}, ) assert response.status_code == 401 assert response.json == { "description": "The token has been revoked.", "error": "token_revoked", } def test_logout_user_no_token(client): response = client.post( "/logout", ) assert response.status_code == 401 assert response.json["description"] == "Request does not contain an access token." def test_logout_user_invalid_token(client): response = client.post( "/logout", headers={"Authorization": "Bearer bad_token"}, ) assert response.status_code == 401 assert response.json == { "error": "invalid_token", "message": "Signature verification failed.", } def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_refresh_token_invalid(client): response = client.post( "/refresh", headers={"Authorization": "Bearer bad_jwt"}, ) assert response.status_code == 401 def test_refresh_token(client, created_user_jwts): response = client.post( "/refresh", headers={"Authorization": f"Bearer {created_user_jwts[1]}"}, ) assert response.status_code == 200 assert response.json["access_token"] def test_refresh_token_twice(client, created_user_jwts): client.post( "/refresh", headers={"Authorization": f"Bearer {created_user_jwts[1]}"}, ) response = client.post( "/refresh", headers={"Authorization": f"Bearer {created_user_jwts[1]}"}, ) assert response.status_code == 401 assert response.json == { "description": "The token has been revoked.", "error": "token_revoked", } ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/end_video/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flake8 ================================================ [flake8] per-file-ignores = __init__.py:F401 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/conftest.py ================================================ import pytest from flask_jwt_extended import create_access_token from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() @pytest.fixture() def jwt(app): with app.app_context(): access_token = create_access_token(identity=1) return access_token @pytest.fixture() def admin_jwt(app): with app.app_context(): access_token = create_access_token( identity=1, additional_claims={"is_admin": True} ) return access_token ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements-dev.txt ================================================ pytest black flake8 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, jwt, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client, jwt): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client, jwt): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, admin_jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {admin_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_delete_item_without_admin(client, jwt, created_item_id): response = client.delete( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 401 assert response.json["message"] == "Admin privilege required." def test_update_item(client, jwt, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client, jwt): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client, jwt): response = client.get( "/item", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, jwt, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, jwt, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client, jwt): response = client.get( "/item/1", headers={"Authorization": f"Bearer {jwt}"}, ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, jwt, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id, jwt): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client, jwt): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, headers={"Authorization": f"Bearer {jwt}"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/__tests__/test_user.py ================================================ import pytest @pytest.fixture() def created_user_details(client): username = "test_user" password = "test_password" client.post( "/register", json={"username": username, "password": password}, ) return username, password @pytest.fixture() def created_user_jwt(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) return response.json["access_token"] def test_register_user(client): username = "test_user" response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 201 assert response.json == {"message": "User created successfully."} def test_register_user_already_exists(client): username = "test_user" client.post( "/register", json={"username": username, "password": "Test Password"}, ) response = client.post( "/register", json={"username": username, "password": "Test Password"}, ) assert response.status_code == 409 assert response.json["message"] == "A user with that username already exists." def test_register_user_missing_data(client): response = client.post( "/register", json={}, ) assert response.status_code == 422 assert "password" in response.json["errors"]["json"] assert "username" in response.json["errors"]["json"] def test_login_user(client, created_user_details): username, password = created_user_details response = client.post( "/login", json={"username": username, "password": password}, ) assert response.status_code == 200 assert response.json["access_token"] def test_login_user_bad_password(client, created_user_details): username, _ = created_user_details response = client.post( "/login", json={"username": username, "password": "bad_password"}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_login_user_bad_username(client, created_user_details): _, password = created_user_details response = client.post( "/login", json={"username": "bad_username", "password": password}, ) assert response.status_code == 401 assert response.json["message"] == "Invalid credentials." def test_logout_user(client, created_user_jwt): response = client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwt}"}, ) assert response.status_code == 200 assert response.json["message"] == "Successfully logged out" def test_logout_user_twice(client, created_user_jwt): client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwt}"}, ) response = client.post( "/logout", headers={"Authorization": f"Bearer {created_user_jwt}"}, ) assert response.status_code == 401 assert response.json == { "description": "The token has been revoked.", "error": "token_revoked", } def test_logout_user_no_token(client): response = client.post( "/logout", ) assert response.status_code == 401 assert response.json["description"] == "Request does not contain an access token." def test_logout_user_invalid_token(client): response = client.post( "/logout", headers={"Authorization": "Bearer bad_token"}, ) assert response.status_code == 401 assert response.json == { "error": "invalid_token", "message": "Signature verification failed.", } def test_get_user_details(client, created_user_details): response = client.get( "/user/1", # assume user id is 1 ) assert response.status_code == 200 assert response.json == { "id": 1, "username": created_user_details[0], } def test_get_user_details_missing(client): response = client.get( "/user/23", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id)) return {"access_token": access_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/08_flask_jwt_extended/12_token_refreshing_flask_jwt_extended/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/08_flask_jwt_extended/Insomnia_section8_before_chaining.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:31:21.063Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_adb596c3d0ee48e2b009a555297f36ac","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667331428779,"created":1667328670383,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_3426c101e9094fa79fbf96cf1ec23fae","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1667328596182,"created":1667328596182,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"wrk_101d091b922e44c28f66528e9ef2ae37","parentId":null,"modified":1666991842388,"created":1666991842388,"name":"Section 8","description":"","scope":"collection","_type":"workspace"},{"_id":"req_fe639dc55a2d439f9aa7a6bceee6a9fa","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667332404117,"created":1667328597818,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5e7ca3df73314f8c839493d53528760b","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667332406439,"created":1667331560707,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_bc66397203734df4b4eb6d153d8d3ec3","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1668007854634,"created":1667332459073,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_953d7fdcaf1f433b9b3b0cdf6453b0cd","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667332353929,"created":1667328704142,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_362ff303b1054bd0b0d62522803aea64","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842419,"created":1666991842419,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_815efb55c04548dea5dfec2e2f69ebcb","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842417,"created":1666991842417,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_f613fc18d27648daa00d6b78deea5b66","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842418,"created":1666991842418,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e899763b1c0a46ad9eb47f9e628aa643","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842422,"created":1666991842422,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_266cac65472a4a118929460e58893fb0","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842421,"created":1666991842421,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e63e0532c2c340aa8ed6643f0ae1b4ec","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842424,"created":1666991842424,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5c6887cfb9c94dc2aa2fe76d525fecac","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842426,"created":1666991842426,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_2ba32c3564f3456aa1c8731323508968","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1666991842400,"created":1666991842400,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_a18660fe822f44828b330ba9a4d3946a","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842399,"created":1666991842399,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"req_047bf72b98214de4a8f711fd46b73eb4","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1666991842403,"created":1666991842403,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_57daa411077044a98d3b2534ee735703","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1667332324728,"created":1666991842402,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6c1af0972dbe4faebf5973d95670c241","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1667332341585,"created":1666991842405,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0d1c09067c6a419a9096b32da16d01a3","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332272845,"created":1666991842412,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842407,"created":1666991842407,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_dbdfc5218bf54bfab39db152b3ffe982","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332288041,"created":1666991842414,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_32c5d69aec8f44fdb33852b456c99b7a","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332259971,"created":1666991842408,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1843f2db017842b993251abbedeb2e8b","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332298429,"created":1666991842410,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d311f219368d46a6b6d92be142a32763","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332311745,"created":1666991842413,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_52ad539864c4425884f6394f62627cb7","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1667329454452,"created":1666991842391,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_bc7c508b3eed4f2485782e9d7177762e","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842395,"created":1666991842395,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_75bd139c1688445faff87b21105f8df1","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842435,"created":1666991842397,"fileName":"Section 8","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/08_flask_jwt_extended/Insomnia_section8_chaining.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-09T15:31:42.216Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_00cda5fbc60a4905bf5b8e67aa7dadb4","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667332566874,"created":1667332566874,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566872,"created":1667332566872,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","parentId":null,"modified":1667332566837,"created":1667332566837,"name":"Section 8 - Chaining","description":"","scope":"collection","_type":"workspace"},{"_id":"req_0ae766df1c25481e83bd6ade061a919b","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667676377948,"created":1667650753174,"url":"{{url}}/refresh","name":"/refresh Get non-fresh token","description":"","method":"POST","body":{},"parameters":[],"headers":[{"id":"pair_d002eea6b22e4190909fc8366b5a4704","name":"Authorization","value":"Bearer {{refresh_token}}","description":""}],"authentication":{},"metaSortKey":-1667328634100.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_252ddd492b9242ffb1a6fe21e25534c5","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667650976611,"created":1667332566872,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_975ec20d9bec44538591da08c95ccd63"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fe8e836ebd4a44cd9f37ff8738e7ed98","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667650979775,"created":1667332566877,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6a638b0dfd3040118a0282fe5a49b5fb","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667332566878,"created":1667332566878,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_229bc7e2601e44cb82eb3e5eafa90202","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667332566875,"created":1667332566875,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ba56440f8f204525a13fbdfbd2273ae0","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566864,"created":1667332566864,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_db9477d576514920b5a9e1d2d82d2254","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566861,"created":1667332566861,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_e2bc3c7dd1c240baad60b3881359bf38","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566862,"created":1667332566862,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ae7151238fe84a65b5b1064b87cef5bd","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566867,"created":1667332566867,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9237adf4f3e74e1b9aa8dbb15680c2f3","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566866,"created":1667332566866,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c11ab9d081754c3e95f0eed61219454a","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566868,"created":1667332566868,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_03468f66652a42dcbd34ef05ce4fd714","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566870,"created":1667332566870,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_22eb90da9d974ac2880b9207d6d11c01","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566845,"created":1667332566845,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_6315121566934d098d5f963bb2b25679","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566844,"created":1667332566844,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"req_44b8b847e3bd4b35bb7c5d2df5e0ee94","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566849,"created":1667332566849,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_3efb83da0cb34711961a59b6b3b39278","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566848,"created":1667332566848,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_24669eae6530416094eb2c746ae577ed","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566851,"created":1667332566851,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_41e02aac89e5447fb70b42a5fbb6e42d","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667650833785,"created":1667332566857,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_68ade160b31f446995815fec3dc30a93","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566853,"created":1667332566853,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_51ea1a9b34584746a8368ede6f6bfadf","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332848385,"created":1667332566859,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69cc615e910a4ca7a91382b455c3ceb5","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332840222,"created":1667332566854,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_310092462fe44643bdbcf671dd488033","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332838232,"created":1667332566855,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_260395e8e71c47559f2c70f50618ea38","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332566858,"created":1667332566858,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_c2a4191124a544e9a440f8042644c7b9","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667651225760,"created":1667332566839,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005","access_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'when-expired', 300 %}","refresh_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5yZWZyZXNoX3Rva2Vu::46b', 'when-expired', NaN %}"},"dataPropertyOrder":{"&":["url","access_token","refresh_token"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_8a4500f913d7401fa00168eaf32dcb4c","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566841,"created":1667332566841,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_2d4dfbfd115b4f91b44d09ad3c6d0b5b","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566888,"created":1667332566842,"fileName":"Section 8 - Chaining","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/08_flask_jwt_extended/_category_.json ================================================ { "label": "User Authentication with Flask-JWT-Extended", "position": 8 } ================================================ FILE: docs/docs/09_flask_migrate/01_why_use_database_migrations/README.md ================================================ --- title: Why use database migrations? description: Learn about database migrations and what they are useful for. ctslug: why-use-database-migrations --- # Why use database migrations? As you work on your application, particularly over a long time, it is unavoidable that you will want to add columns to your models, or even add new models entirely. Making the changes directly to the models without something like Alembic and Flask-Migrate will mean that the existing database tables and the model definitions will be out of sync. When that happens, SQLAlchemy usually complains and your application won't work. An option is to delete everything and get SQLAlchemy to re-create the tables. Obviously, this is not good if you have data in the database as you would lose all the data. We can use Alembic to detect the changes to the models, and what steps are necessary to "upgrade" the database so it matches the new models. Then we can use Alembic to actually modify the database following the upgrade steps. Alembic also tracks each of these migrations over time, so that you can easily go to a past version of the database. This is useful if bugs are introduced or the feature requirements change. Since Alembic tracks all the migrations over time, we can use it to create the tables from scratch, simply by applying the migrations one at a time until we reach the latest one (which should be equal to the current one). ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/README.md ================================================ --- title: How to add Flask-Migrate to our Flask app description: Integrating your Flask app with Flask-Migrate is relatively straightforward. Learn how to do it in this lecture. ctslug: how-to-add-flask-migrate-to-our-flask-app --- # How to add Flask-Migrate to our Flask app Adding Flask-Migrate to our app is simple, just install it and add a couple lines to `app.py`. To install: ```bash pip install flask-migrate ``` This will also install Alembic, since it is a dependency. Then we need to add 2 lines to `app.py` (highlighted): ```py from flask_smorest import Api # highlight-start from flask_migrate import Migrate # highlight-end import models app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) # highlight-start migrate = Migrate(app, db) # highlight-end api = Api(app) with app.app_context(): db.create_all() ``` Since we will be using Flask-Migrate to create our database, we no longer need to tell Flask-SQLAlchemy to do it when we create the app. Delete these two lines: ```py with app.app_context(): db.create_all() ``` ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn Flask-Migrate ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/09_flask_migrate/02_add_flask_migrate_to_app/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/README.md ================================================ --- title: Initialize your database with Flask-Migrate description: "Learn the first steps when starting with Flask-Migrate: initializing the database." ctslug: initialize-database-with-flask-migrate --- # Initialize the database with Flask-Migrate Activate your virtual environment and run this command: ``` flask db init ``` This will create a `migrations` folder inside your project folder. In the `migrations` folder you'll find a few things: - The `versions` folder is where migration scripts will be placed. These will be used by Alembic to make changes to the database. - `alembic.ini` is the Alembic configuration file. - `env.py` is a script used by Alembic to generate migration files. - `script.py.mako` is the template file for migration files. ## Generate the first migration to set up the database Now that we're set up, we need to make sure that the database we want to use is currently empty. In our case, since we're using SQLite, just delete `data.db`. Then, run this command: ``` flask db migrate ``` This will create the migration file. :::caution It's important to double-check the migration script and make sure it is correct! Compare it with your model definitions and make sure nothing is missing. ::: Now let's actually apply the migration: ``` flask db upgrade ``` This will create the `data.db` file. If you were using another RDBMS (like PostgreSQL or MySQL), this command would create the tables using the existing model definitions. :::info How does the database know which version it's on? When using Alembic to create the database tables from scratch, it creates an extra table with a single row, that stores the current migration version. You'll note in each migration script there is information about the previous migration and the next migration. This is why it's important to **never rename the migration files or change the revision identifiers at the top of those files**. ::: ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/.python-version ================================================ 3.11 ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') def get_engine(): try: # this works with Flask-SQLAlchemy<3 and Alchemical return current_app.extensions['migrate'].db.get_engine() except TypeError: # this works with Flask-SQLAlchemy>=3 return current_app.extensions['migrate'].db.engine # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(get_engine().url).replace('%', '%%')) target_db = current_app.extensions['migrate'].db # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def get_metadata(): if hasattr(target_db, 'metadatas'): return target_db.metadatas[None] return target_db.metadata def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=get_metadata(), literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=get_metadata(), process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/migrations/versions/c575166f6192_.py ================================================ """empty message Revision ID: c575166f6192 Revises: Create Date: 2023-01-23 15:14:42.094596 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'c575166f6192' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn Flask-Migrate ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn Flask-Migrate ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/09_flask_migrate/03_initialize_database_flask_db_init/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/README.md ================================================ --- title: Change SQLAlchemy models and generate a migration description: Use Flask-Migrate to generate a new database migration after changing your SQLAlchemy models. ctslug: change-sqlalchemy-models-generate-migration --- # Change SQLAlchemy models and generate a migration Let's make a change to one of our SQLAlchemy models and then generate another migration script. This is what we will do every time we want to make changes to our models and our database schema. ```python title="models/item.py" from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) # highlight-start description = db.Column(db.String) # highlight-end price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ``` Here we're adding a simple column, just a string that doesn't have any constraints. Now let's go to the terminal and run the command: ``` flask db migrate ``` This will now generate _another migration script_ that you have to double-check. Make sure to check the upgrade and downgrade functions. When you're happy with the contents, apply the migration: ``` flask db upgrade ``` ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/.python-version ================================================ 3.11 ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') def get_engine(): try: # this works with Flask-SQLAlchemy<3 and Alchemical return current_app.extensions['migrate'].db.get_engine() except TypeError: # this works with Flask-SQLAlchemy>=3 return current_app.extensions['migrate'].db.engine # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(get_engine().url).replace('%', '%%')) target_db = current_app.extensions['migrate'].db # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def get_metadata(): if hasattr(target_db, 'metadatas'): return target_db.metadatas[None] return target_db.metadata def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=get_metadata(), literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=get_metadata(), process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/bcc005bc255c_.py ================================================ """empty message Revision ID: bcc005bc255c Revises: c575166f6192 Create Date: 2023-01-23 15:21:21.002304 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bcc005bc255c' down_revision = 'c575166f6192' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table('items', schema=None) as batch_op: batch_op.add_column(sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table('items', schema=None) as batch_op: batch_op.drop_column('description') # ### end Alembic commands ### ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/migrations/versions/c575166f6192_.py ================================================ """empty message Revision ID: c575166f6192 Revises: Create Date: 2023-01-23 15:14:42.094596 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'c575166f6192' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn Flask-Migrate ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/__init__.py ================================================ ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.dockerignore ================================================ .venv *.pyc __pycache__ data.db ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/.python-version ================================================ 3.11 ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) # @jwt.additional_claims_loader # def add_claims_to_jwt(identity): # # TODO: Read from a config file instead of hard-coding # if identity == 1: # return {"is_admin": True} # return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') def get_engine(): try: # this works with Flask-SQLAlchemy<3 and Alchemical return current_app.extensions['migrate'].db.get_engine() except TypeError: # this works with Flask-SQLAlchemy>=3 return current_app.extensions['migrate'].db.engine # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(get_engine().url).replace('%', '%%')) target_db = current_app.extensions['migrate'].db # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def get_metadata(): if hasattr(target_db, 'metadatas'): return target_db.metadatas[None] return target_db.metadata def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=get_metadata(), literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=get_metadata(), process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/migrations/versions/c575166f6192_.py ================================================ """empty message Revision ID: c575166f6192 Revises: Create Date: 2023-01-23 15:14:42.094596 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'c575166f6192' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn Flask-Migrate ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/__init__.py ================================================ ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from db import db from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) # Make it clear that when to add the refresh token to the blocklist will depend on the app design jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"access_token": new_token}, 200 ================================================ FILE: docs/docs/09_flask_migrate/04_change_models_generate_alembic_migration/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) ================================================ FILE: docs/docs/09_flask_migrate/05_manually_review_modify_migrations/README.md ================================================ --- title: Manually review and modify database migrations description: Alembic can generate database migrations parting from model changes, but sometimes we need to modify them manually. ctslug: manually-review-modify-database-migrations --- # Manually review and modify database migrations ## Default column values When you add a column that uses a default value, any rows that existed previously will have `null` as the value. You'll have to go into the database to add the default value to those rows. You can also do this during the migration, since Alembic migrations can execute any arbitrary SQL queries. Here's an example for a column being added with a default value: ```py title="migrations/versions/sample_migration.py" from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "9c386e4052be" down_revision = "713af8a4cb34" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column( "invoices", sa.Column("enable_downloads", sa.Boolean(), nullable=True, default=False), ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column("invoices", "enable_downloads") # ### end Alembic commands ### ``` You can see that we're adding a column called `enable_downloads` to the `invoices` table. The default value for new rows will be `False`, but what is the value for all the invoices we already have in the database? The value will be undefined, or `null`. What we must do is tell Alembic to insert `False` into each of the existing rows. We can do that with SQL: ```py title="migrations/versions/sample_migration.py" from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "9c386e4052be" down_revision = "713af8a4cb34" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column( "invoices", sa.Column("enable_downloads", sa.Boolean(), nullable=True, default=False), ) # highlight-start op.execute("UPDATE invoices SET enable_downloads = False") # highlight-end # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column("invoices", "enable_downloads") # ### end Alembic commands ### ``` ================================================ FILE: docs/docs/09_flask_migrate/_category_.json ================================================ { "label": "Database migrations with Alembic and Flask-Migrate", "position": 9 } ================================================ FILE: docs/docs/10_git_crash_course/README.md ================================================ # Git Crash Course The Git Crash Course e-book is hosted in a different page because it is used in multiple courses. Read the Git Crash Course e-book here: [https://git-workshop.tecladocode.com/](https://git-workshop.tecladocode.com/) ================================================ FILE: docs/docs/10_git_crash_course/_category_.json ================================================ { "label": "Git Crash Course", "position": 10 } ================================================ FILE: docs/docs/11_deploy_to_render/01_section_overview/README.md ================================================ --- ctslug: overview-of-this-section --- # Overview of this section :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! ::: In this section, we will figure out how to get our Flask app and put it on a public server so other people can interact with it! This is called "deploying". There are many services we can use to deploy our app. Most of them have some sort of "free tier" so that you can try the deployment without having to pay anything. Usually, if you want better performance or unlimited usage, you have to pay. Remember that just as we run the Flask app in our computers, when we deploy it the app runs in a server, somewhere in the world. For all intents and purposes, the server is just like our computer! Servers usually run Linux, so we can deploy our Docker images without a performance hit as we would using Mac or Windows. At the end of the section, you'll be able to access your API using a URL such as [https://rest-api-smorest-docker.onrender.com](https://rest-api-smorest-docker.onrender.com). For this section, our deployment will be completely free. We will deploy our Flask app for free, and we will also get a free PostgreSQL database on the cloud using ElephantSQL. ================================================ FILE: docs/docs/11_deploy_to_render/02_create_render_web_service/README.md ================================================ --- ctslug: creating-a-render-com-web-service --- # Creating a Render.com web service Let's start by going to [https://render.com](https://render.com) and signing up to an account. You can "Log in with GitHub" to make things easier. Once you've logged in, you'll see in your [Dashboard](https://dashboard.render.com/services) that you can create a new service using a button at the top right of the page. Click it, and select "Web Service". Options other than "Web Service" are useful for different kinds of applications, and some are databases that you can use (but not for free, so we won't use Render for our database in this section). Then you'll [connect your GitHub account](https://render.com/docs/github) if you haven't already, and look for your repositories. Select the repository that you created during this course: ![Render.com screenshot showing how to search for and select a repository to connect to from GitHub](https://res.cloudinary.com/teclado/image/upload/v1689180776/courses/rest-apis-flask-python/render-github-connect_jp6mip.png) Then, give it a name and make sure the configuration is as follows: ![Render.com screenshot showing the web service configuration](https://res.cloudinary.com/teclado/image/upload/v1689180776/courses/rest-apis-flask-python/render-service-config_poweeb.png) - Make sure "Docker" is selected. - Select a server location close to you. I'm near Frankfurt, but if you are in the US or Asia you might want to choose a different one so it's faster to connect to. - Select the "Free" server option. At the bottom of the service there is an "Advanced" section which you can use to further configure your service. We'll talk more about that in a bit. For now, hit "Create Web Service" and wait for it to deploy your code from GitHub! If you navigate to your Dashboard and then click through to your newly created service, you'll be able to see the service details. If it isn't already deploying, click on the "Manual Deploy" button on the top right to initiate a deploy of the latest commit: ![](https://res.cloudinary.com/teclado/image/upload/v1689180775/courses/rest-apis-flask-python/deploy-latest-commit_k9as13.png) Then you should start seeing logs appear detailing the deployment process! ![](https://res.cloudinary.com/teclado/image/upload/v1689180776/courses/rest-apis-flask-python/render-deploy-screen_lfx1uh.png) While on the free plan, deployments are a bit slow. It has to build your image and run it! Give it a few minutes, until the deployment succeeds. You should see this: ![](https://res.cloudinary.com/teclado/image/upload/v1689180776/courses/rest-apis-flask-python/render-deploy-finished_lyiftz.png) Now, you can access your service URL and try it out using Insomnia or Postman! :::warning If `data.db` is in the `.gitignore` file you need to call `flask db migrate` to populate the sqlite database. Since the free plan does not provide the CLI, you can do this in the `Dockerfile` by adding `RUN flask db upgrade` after `COPY . .`. ::: ![](https://res.cloudinary.com/teclado/image/upload/v1689180778/courses/rest-apis-flask-python/insomnia-test-prod_dlfe1d.png) :::warning Free services in Render.com shut down after inactivity for a few minutes. If you don't use your service for a few minutes, it will shut down and it will need to restart, which can take a minute! This is one of the limitations of their free plan. ::: ================================================ FILE: docs/docs/11_deploy_to_render/03_docker_with_gunicorn/README.md ================================================ --- ctslug: run-our-flask-app-with-gunicorn --- # Run our Flask app with gunicorn in Docker Throughout the course, we've been working with a Docker image like this one: ```dockerfile FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ``` This is all well and good for local development, but when we deploy our application we want to run it with the best performance possible. This is why we don't want to run the Flask development server and the Flask debugger. Instead, we'll use gunicorn to run our app. ## Run our Flask app with gunicorn First let's add `gunicorn` to our `requirements.txt` file: ```text title="requirements.txt" flask flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate # highlight-start gunicorn # highlight-end ``` Then, let's change our `Dockerfile` to use `gunicorn`: ```dockerfile FROM python:3.10 WORKDIR /app COPY ./requirements.txt requirements.txt # highlight-start RUN pip install --no-cache-dir --upgrade -r requirements.txt # highlight-end COPY . . # highlight-start CMD ["gunicorn", "--bind", "0.0.0.0:80", "app:create_app()"] # highlight-end ``` The `CMD` line change is the important one, as it runs `gunicorn` on port `80`, and we pass in the app factory function. :::tip Note I've also changed the `pip install` line. Adding `--no-cache-dir` and `--upgrade` just makes sure we can't accidentally install from a cache directory (which shouldn't exist anyway!), and that we'll upgrade to the latest possible versions allowed by our `requirements.txt` file. ::: ## Run the Docker container locally with the Flask development server and debugger If you use this `Dockerfile`, it doesn't mean you can't run it locally using the Flask development server. You don't have to lose the automatic restarting capabilities, or the Flask debugger. To run the Docker container locally, you'll have to do this from now on: ```zsh docker run -dp 5000:5000 -w /app -v "$(pwd):/app" teclado-site-flask sh -c "flask run --host 0.0.0.0" ``` This is similar to how we've ran the Docker container with our local code as a volume (that's what `-w /app -v "$(pwd):/app"` does), but at the end of the command we're telling the container to run `flask run --host 0.0.0.0` instead of the `CMD` line of the `Dockerfile`. That's what `sh -c "flask run --host 0.0.0.0"` does! Now you're ready to commit and push this to your repository and re-deploy to Render.com! ================================================ FILE: docs/docs/11_deploy_to_render/04_deploy_postgresql_database/README.md ================================================ --- ctslug: how-to-get-a-deployed-postgresql-database --- # How to get a deployed PostgreSQL database for our app There are many PostgreSQL-as-a-Service providers (that means, services that handle creating and maintaining your database for you). Render.com offers PostgreSQL, and the pricing is actually quite fair. However, the free tier is limited and you can only use it for a certain amount of time. That's why I recommend using ElephantSQL for your free PostgreSQL needs. When you go over the free ElephantSQL limits, then you can use the Render.com database instead. To get a free ElephantSQL PostgreSQL database, just go to their site, sign up, and then create a database in a region close to your Render.com server. Make sure to select the free tier. ![ElephantSQL screenshot showing plan configuration of Tiny Turtle (free) and name](https://res.cloudinary.com/teclado/image/upload/v1689180780/courses/rest-apis-flask-python/select-plan-and-name-elephantsql_sx3v2e.png) Once you've got this, you should be able to see the Database URL: ![ElephantSQL screenshot showing that a copy icon beside the Database URL can be clicked to copy it](https://res.cloudinary.com/teclado/image/upload/v1689180780/courses/rest-apis-flask-python/copy-elephantsql-url_ydr90k.png) Copy this, as you'll need it in the next lecture! ================================================ FILE: docs/docs/11_deploy_to_render/05_environment_variables_and_migrations/README.md ================================================ --- ctslug: how-to-use-environment-variables-in-render-com --- # How to use Environment Variables in Render.com A common way to configure applications before they start up is by using environment variables. We can define environment variables in our computers, and also in our servers, and of course they can be different in each. That's what's interesting about them: we can define an environment variable locally for our database, which may be `sqlite:///data.db`. Then in our server we can define the same variable, but with a value of the ElephantSQL Database URL. Since we are using SQLAlchemy in our application, it doesn't care whether it's connecting to SQLite or PostgreSQL. So all we have to do to use a different database is change the connection string. Let's begin by using environment variables locally. ## Using PostgreSQL locally Since we are going to be using PostgreSQL when we deploy, it's a good idea to use PostgreSQL also locally. That's because SQLite and PostgreSQL behave a bit differently, so if we use SQLite locally and PostgreSQL in production, we may come across issues. To work with PostgreSQL locally, you can run a PostgreSQL container using Docker, you can install PostgreSQL locally, or you can create another ElephantSQL database for local development. I would do the last option. That way, you'll have 2 ElephantSQL databases; one for production and one for development. ## How to use environment variables locally with our Flask app First let's install `psycopg2` and add it to our `requirements.txt` file: ```text title="requirements.txt" flask flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn # highlight-start psycopg2 # highlight-end ``` Then, let's create a new file called `.env`. In this file, we can store any environment variables we want. We can then "load" these variables when we start the app. ```text title=".env" DATABASE_URL=postgresql://YOUR_DEVELOPMENT_URL ``` :::warning The ElephantSQL URL starts with `postgres://...`. Make sure to change it so it starts with `postgresql://...`! ::: With the file created, we can load it when we start our Flask app: ```python title="app.py" # highlight-start import os # highlight-end from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager # highlight-start from dotenv import load_dotenv # highlight-end from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) # highlight-start load_dotenv() # highlight-end app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" # highlight-start app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") # highlight-end app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) ``` Highlighted are four lines which we must change. 1. First we `import os`. We'll need this to access environment variables. 2. Second, we import the `load_dotenv` function, which we'll need to run in order to turn the contents of the `.env` file into environment variables. 3. We actually run the `load_dotenv` function. 4. We'll use `db_url` if provided, otherwise we'll retrieve the environment variable's value. If there is no environment value, the default will be `"sqlite:///data.db"`. Notice that our Flask app has two ways to be configured: with the `db_url` argument, or via environment variables. You would normally use `db_url` when writing automated tests for your application. While we don't do that in this course, it's a good habit to get into! :::warning Do not include your `.env` file in your GitHub repository! Add it to `.gitignore` so you don't include it accidentally. ::: Since we can't include `.env` in our GitHub repository, we should do something to make sure that new developers know that they should create a `.env` file when they clone the repository. We normally do this by creating a file called `.env.example`. This file should only contain the environment variable definitions, but not the values: ```text title=".env.example" DATABASE_URL= ``` You should add `.env.example` to your repository. ## Changes needed to our app code for PostgreSQL We've been working with SQLite all this time, and PostgreSQL behaves a bit differently. There are a couple of changes we need to make to our app at this point: 1. Make sure all foreign keys are the same data type as the primary keys they reference. 2. Change the length constraint on user passwords from `80` to `256`. This is because SQLite doesn't enforce either of these constraints, so although they were a problem before, we didn't know because SQLite didn't tell us about it. PostgreSQL will complain! ### Changes to foreign keys The only foreign key that was mistakenly given the wrong data type was in the `TagModel`. This is the necessary change: ```python title="models/tag.py" from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) # highlight-start store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) # highlight-end store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ``` We also need to change the database migration file that creates the store ID: ```python title="migrations/versions/07006e31e788_.py" ... op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), # highlight-start sa.Column('store_id', sa.Integer(), nullable=False), # highlight-end sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) ... ``` Now, let's run the migrations so that our development ElephantSQL database is created. Remember to make sure that your development ElephantSQL database is empty before starting the migrations. ```bash flask db upgrade ``` ### Changes to password length In the `UserModel`, we'll make this change: ```python title="models/user.py" from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) # highlight-start password = db.Column(db.String(256), nullable=False) # highlight-end ``` ### Running our migration with string length changes Now we want to create a new migration so that our changes to the `UserModel` will be applied: ```bash flask db migrate ``` This may add a couple other data type changes, such as changing `REAL` to `Float`. This is due to how types are assigned differently between SQLite and PostgreSQL. Make sure that the password length change is in the migration: ```python title="migrations/versions/36e961f62882_.py" op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) ``` ## Running database migrations in production So we've created our migration files and we've migrated our development database. What about our production database? We _could_ simply change our `.env` file, connect to production, and migrate that database. But then we'd need to remember to do that every time before we deploy, and it simply isn't feasible. Instead, we want a solution where the database migrations run before the app starts. That way, it will be impossible for us to forget to run the migrations when we deploy. To do so, we'll tell the Docker container to run the database migrations before starting the `gunicorn` process. It's more straightforward than it sounds! First let's write a very short bash script that runs the migrations, and then starts the gunicorn process: ```bash title="docker-entrypoint.sh" #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ``` Then, let's modify our `Dockerfile` to use that script: ```dockerfile FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . # highlight-start CMD ["/bin/bash", "docker-entrypoint.sh"] # highlight-end ``` :::tip If you want to run the Docker container locally with the Flask development server, our [previous instructions](/docs/deploy_to_render/docker_with_gunicorn/#run-the-docker-container-locally-with-the-flask-development-server-and-debugger) are still good. You won't be applying the migrations, but most of the time that won't be a problem. ::: Commit the changes, and push them to GitHub. We'll need these changes so we can use environment variables in Render.com. ## How to add environment variables to Render.com Now that our Flask app is using environment variables, all we have to do is add the `DATABASE_URL` environment variable to our Render.com service, and then deploy the latest changes from our GitHub repository. To add environment variables in Render.com, go to the service settings and then on the left you'll see "Environment": ![Render.com screenshot showing the button to add a environment variables](https://res.cloudinary.com/teclado/image/upload/v1689180783/courses/rest-apis-flask-python/render-add-env-var_lodpee.png) Click on "Add Environment Variable", and there put `DATABASE_URL` as the key, and your ElephantSQL Database URL as the value: ![Render.com screenshot showing DATABASE_URL added with a pixelated value](https://res.cloudinary.com/teclado/image/upload/v1689180784/courses/rest-apis-flask-python/render-database-url-env-var_wrxgjl.png) :::warning Again, make sure to use `postgresql://...` here. ::: Now, do another manual deploy of the latest commit. When this is done, your app should be saving to the ElephantSQL database, and it will apply the migrations before starting up! [^alembic_docs]: [Compare Types (Alembic official documentation)](https://alembic.sqlalchemy.org/en/latest/autogenerate.html#compare-types) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/README.md ================================================ # How to run the app and database with Docker Compose Up until now we've been running `docker compose up` to start the REST API container. Now let's modify our `docker-compose.yml` file to include spinning up a new PostgreSQL database. ```yaml services: web: build: . ports: - "5000:80" depends_on: db: condition: service_healthy env_file: - ./.env volumes: - .:/app db: image: postgres environment: - POSTGRES_PASSWORD=password - POSTGRES_DB=myapp volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: pg_isready -d $${POSTGRES_DB} -U postgres interval: 2s retries: 10 volumes: postgres_data: ``` The `postgres` image accepts various environment variables, among them: - `POSTGRES_PASSWORD`, defaulting to `postgres` - `POSTGRES_DB`, defaulting to `postgres` - `POSTGRES_USER`, defaulting to `postgres` - `POSTGRES_HOST`, defaulting to `localhost` - `POSTGRES_PORT`, defaulting to `5432` We should at least set a secure password. Above we're changing the password and database to `password` and `myapp` respectively. :::caution Remember to also change your `DATABASE_URL` in your `.env` file that the REST API container is using. It should look like this: ``` DATABASE_URL=postgresql://postgres:password@db/myapp ``` When Docker Compose runs, it creates a virtual network[^1] which allows you to connect to `db`, which connects to the running `db` service container. ::: In the `docker-compose.yml` file above you can also see that the `web` service depends on the `db` service, with the condition that it is healthy. A service is deemed "healthy" when its healthcheck passes. We've added a healthcheck to the `db` service which runs the `pg_isready`[^2] program using the supplied database and PostgreSQL user. This just tells us whether the PostgreSQL server is ready to respond to requests. Adding this means the `web` service won't start until the `db` service is ready to respond to requests. ## Named volumes in Docker Compose You'll notice that our `docker-compose.yml` file has these lines: ``` volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data: ``` The bottom two lines define a named volume. This is data that will be stored by Docker and can be reused across container runs. We're calling it `postgres_data`, but it isn't assigned to anything there. In the top two lines, which are part of the `db` service definition, we say that the `postgres_data` named volume is mapped to `/var/lib/postgresql/data` in the container. `/var/lib/postgresql/data` is where the `postgres` image saves PostgreSQL data (such as databases, tables, etc). Therefore, as you create databases, tables, and store data, the named volume `postgres_data` will contain them. When you restart the container (or even rebuilt it), you can use the same named volume to keep access to old data. If you want to delete the entire database content, you can do so by deleting the volume through Docker Desktop, or with this command: ``` docker compose down -v ``` ## Starting the whole system Now you're ready to start the Docker Compose system! If you need to rebuild the REST API container first, run: ``` docker compose up --build --force-recreate --no-deps web ``` You'll get an error due to no database being available. That's OK, as long as the container is rebuilt! Then press `CTRL+C` to stop it, and start the whole system with: ``` docker compose up ``` Now you can make a request to your API on port 5000, and it should work, storing the data in the database! ## Running the system in background mode When we run the system with `docker compose up`, it takes up the terminal until we stop it with `CTRL+C`. If you want to run it in "Daemon" mode, or in the background, so you can use the terminal for other things, you can use: ``` docker compose up -d ``` Then to stop the system, use: ``` docker compose down ``` Note you must be in the folder that contains your `docker-compose.yml` file in order to bring the system up or down. :::warning Running `docker compose down` will **not** delete your named volumes. You need to use the `-v` flag for that. Deleting the named volumes deletes the data in them irreversibly. ::: [^1]: [Networking in Compose (official docs)](https://docs.docker.com/compose/networking/) [^2]: [pg_isready (PostgreSQL documentation)](https://www.postgresql.org/docs/current/app-pg-isready.html) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/docker-compose.yml ================================================ services: web: build: . ports: - "5000:80" depends_on: - db env_file: - ./.env volumes: - .:/app db: image: postgres environment: - POSTGRES_PASSWORD=password - POSTGRES_DB=myapp volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data: ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]) ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]) ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/11_deploy_to_render/06_run_everything_docker_compose/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) ================================================ FILE: docs/docs/11_deploy_to_render/Insomnia_section11.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-18T22:14:08.036Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_92cec23efa0d47a582b4cf476fbe1c7d","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668007660695,"created":1668007660695,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_8173221215364faa9eaf4de28d0b227d","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660692,"created":1668007660692,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"wrk_f14186e0f24a4da9ab3ab8d628227a36","parentId":null,"modified":1668007660646,"created":1668007660646,"name":"Section 11","description":"","scope":"collection","_type":"workspace"},{"_id":"req_70c9c31ae27e47b684428bb88932f96d","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668007660702,"created":1668007660702,"url":"{{url}}/refresh","name":"/refresh Get non-fresh token","description":"","method":"POST","body":{},"parameters":[],"headers":[{"id":"pair_d002eea6b22e4190909fc8366b5a4704","name":"Authorization","value":"Bearer {{refresh_token}}","description":""}],"authentication":{},"metaSortKey":-1667328634100.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_a9790bade2c14dd5a87b85088017aa92","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668809637607,"created":1668007660694,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_975ec20d9bec44538591da08c95ccd63"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0aa8a2f045e94cea96ba0f7061a26c41","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668678383934,"created":1668007660698,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1a5f0c9aa22841e0a60e47c6a5f65edc","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668678384453,"created":1668007660701,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_969a6a61d9b74bd3ad27623663999dca","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668007660697,"created":1668007660697,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8b7c05ec34874814bbb1ed17f23d7ea3","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660682,"created":1668007660682,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_3083883073354f11bb6c2aa2fcbe76d3","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660679,"created":1668007660679,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_c063f1996fde409f81d62a0e3d20b631","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660681,"created":1668007660681,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_007ed54990944618a9e4bbf4fed9bdc8","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660686,"created":1668007660686,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5739a9e1be2e4fcd8ecdac5142b5e76d","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668678381399,"created":1668007660684,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fbc4905447b04feca058e6f8c0428acf","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660688,"created":1668007660688,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_839409cd69e945b694d46f3101eec0d5","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660690,"created":1668007660690,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fe2a4b3d67664cd194efe09393b9fd02","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668007660660,"created":1668007660660,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_db95f405b201413889d2ae8918eee2d4","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660658,"created":1668007660658,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"req_b34295f6d8b24000b77f812c9cc2c2cc","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668007660663,"created":1668007660663,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_90015fc3fcae4301b0d8a5c0355a3d83","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668678375918,"created":1668007660661,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0e20a55b16034f06a3246c4e76f1c387","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668007660665,"created":1668007660665,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_671694da515a4c4e877008b02cd17f57","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668007660673,"created":1668007660673,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_11fd3ae720264f0b9480c6277f1a7633","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660668,"created":1668007660668,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_a55050436885439f8393e70546d75641","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668007660676,"created":1668007660676,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b8cc399abc8749eea66c6967522f8a36","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668678378059,"created":1668007660669,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8e9e92854eab4e74af765725b54c7d31","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668007660671,"created":1668007660671,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5303d646b4c44843bc9c8322675f9c54","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668678378608,"created":1668007660675,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_e09818240d524d079150f6fc00963c54","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660648,"created":1668007660648,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005","access_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'when-expired', 300 %}","refresh_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5yZWZyZXNoX3Rva2Vu::46b', 'when-expired', NaN %}"},"dataPropertyOrder":{"&":["url","access_token","refresh_token"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_9aa71c77793345cabd413a66ad557bd7","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660652,"created":1668007660652,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_2f42797dc9c6441fb3d3e7b23780dc27","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660725,"created":1668007660654,"fileName":"Section 11","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/11_deploy_to_render/_category_.json ================================================ { "label": "Deploy REST APIs to Render", "position": 11 } ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/README.md ================================================ --- ctslug: how-to-send-emails-with-python-mailgun --- # How to send emails with Python and Mailgun :::tip Insomnia files Remember to get the Insomnia files for this section or for all sections [here](/insomnia-files/)! ::: To send e-mails using Python, we are going to use Mailgun, a third party service which actually delivers the messages. You could use [your own personal account and the built-in `email` and `smtp` libraries](https://blog.teclado.com/learn-python-send-emails/), but most personal e-mail providers will limit how many e-mails you can send per day. Plus, you won't get analytics and a host of other features that you can get with an email service like Mailgun. There are two ways to use the Mailgun service: [via SMTP or via their API](https://www.mailgun.com/blog/email/difference-between-smtp-and-api/). I'll show you how to use the API since it's a bit easier and has the same functionality. Sending an e-mail with Mailgun is just a matter of sending a request to their API. To do this, we'll use the `requests` library: ```bash pip install requests ``` Remember to add it to your `requirements.txt` as well: ```text title="requirements.txt" requests ``` ## Setting up for Mailgun Before we can send any emails, we need to set up our Mailgun account. First, register over at [https://mailgun.com](https://mailgun.com). Once you have registered, select your sandbox domain. It's in [your dashboard](https://app.mailgun.com/app/dashboard), at the bottom. It looks like this: `sandbox847487f8g78.mailgun.org`. Then at the top right, enter your personal email address under "Authorized recipients". You will get an email to confirm. Click the button that you see in that email to add your personal email to the list of authorized recipients. Next up, grab your API key. You can find it by clicking on this button (my domain and API key are blurred in this screenshot): ![Click the 'Select' button to reveal your Mailgun API key](https://res.cloudinary.com/teclado/image/upload/v1689180789/courses/rest-apis-flask-python/mailgun-api-key_mc8bjz.png) ## Sending emails with Mailgun To make the API request which sends an email, we'll use a function that looks very much like this one (taken from their documentation): ```py def send_simple_message(): return requests.post( "https://api.mailgun.net/v3/YOUR_DOMAIN_NAME/messages", auth=("api", "YOUR_API_KEY"), data={"from": "Excited User ", "to": ["bar@example.com", "YOU@YOUR_DOMAIN_NAME"], "subject": "Hello", "text": "Testing some Mailgun awesomness!"}) ``` So let's go into our User resource and add a couple of imports and this function. Make sure to replace "Your Name" with your actual name or that of your application: ```py title="resources/user.py" import os import requests ... def send_simple_message(to, subject, body): domain = os.getenv("MAILGUN_DOMAIN") return requests.post( f"https://api.mailgun.net/v3/{domain}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) ``` Then let's go to the `.env` file and add your Mailgun API key and domain: ```text title=".env" MAILGUN_API_KEY="" MAILGUN_DOMAIN="" ``` :::info The API Key should look something like this: `"1f1ahfjhf4878797887187j-5ac54n"`. The Domain should look something like this: `"sandbox723b05d9.mailgun.org"` ::: With this, we're ready to actually send emails! ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/resources/user.py ================================================ import os import requests from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") def send_simple_message(to, subject, body): domain = os.getenv("MAILGUN_DOMAIN") return requests.post( f"https://api.mailgun.net/v3/{domain}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]) ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/01_send_emails_python_mailgun/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/README.md ================================================ --- ctslug: sending-emails-when-users-register --- # Sending emails when users register If we want to be able to send emails to users when they register, we'll need to: - Add an `email` column to the user model. - Collect user email addresses when users register. Let's begin with the model. ## Add an `email` column to the user model ```diff title="models/user.py" + email = db.Column(db.String, unique=True, nullable=False) ``` Then run the migration as we've already learned, to generate the migration script and upgrade the database to include the new column: ```bash flask db migrate ``` Now let's check the migration script. It should include adding the `email` column, and making it unique. Make sure that the `UniqueConstraint` is given a name. Alembic won't do this for you. Instead, it gives it the name `None` by default: ```py op.create_unique_constraint(None, 'users', ['email']) ``` Change that to this: ```py op.create_unique_constraint("email", 'users', ['email']) ``` And also when dropping the constraint: ```py op.drop_constraint("email", 'users', type_='unique') ``` ```bash flask db upgrade # make sure this is using the local dev database ``` ## Collect user email addresses when they register To do this, first let's add an `email` field to the incoming data. Remember that we use the `UserSchema` for this in our API, but at the moment we are using `UserSchema` for two things: registration and login. If we modify `UserSchema` to add an email field, users will need to give us their username, email, and password when they log in. So it's better to keep two schemas: one for registration, which asks for an email, and one for login, which only asks for the username. ```py class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True, load_only=True) # highlight-start class UserRegisterSchema(UserSchema): email = fields.Str(required=True) # highlight-end ``` :::info You could also get rid of usernames and only use emails. You can use email/password for login in that case! ::: Now that we've got that, we can actually use the email field to create our `UserModel` objects: ```py title="resources/user.py" from schemas import UserSchema, UserRegisterSchema ... @blp.route("/register") class UserRegister(MethodView): # highlight-start @blp.arguments(UserRegisterSchema) # highlight-end def post(self, user_data): ... user = UserModel( username=user_data["username"], # highlight-start email=user_data["email"], # highlight-end password=pbkdf2_sha256.hash(user_data["password"]), ) ``` Now we can use the `send_simple_message` function [we defined earlier](../01_send_emails_python_mailgun/README.md#sending-emails-with-mailgun) to actually send an email! ```py title="resources/user.py" @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() # highlight-start send_simple_message( to=user.email, subject="Successfully signed up", body=f"Hi {user.username}! You have successfully signed up to the Stores REST API." ) # highlight-end return {"message": "User created successfully."}, 201 ``` ## Error handling duplicate emails In our `UserRegister` resource we are checking for duplicate usernames, but we should also check for duplicate emails. Otherwise, if a user tries to sign up with an email that already exists in the database, they'll get an ugly error. ```py title="resources/user.py" from sqlalchemy import or_ ... @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"] ) ).first(): abort(409, message="A user with that username or email already exists.") # ... Method continues here ... ``` So voilà, we're now sending an email when a user signs up! But sending an email can take a non-trivial amount of time... Wouldn't it be nice if we could offload the task of sending emails to another process, so that it happens in the background without our API user having to wait? ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/.python-version ================================================ 3.10.6 ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "bb5da1e68550" down_revision = "8ca023a4a4b0" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column( "items", "price", existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False, ) op.alter_column( "users", "password", existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False, ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column( "users", "password", existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False, ) op.alter_column( "items", "price", existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False, ) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/resources/user.py ================================================ import os import requests from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema blp = Blueprint("Users", "users", description="Operations on users") def send_simple_message(to, subject, body): domain = os.getenv("MAILGUN_DOMAIN") return requests.post( f"https://api.mailgun.net/v3/{domain}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() send_simple_message( to=user.email, subject="Successfully signed up", body=f"Hi {user.username}! You have successfully signed up to the Stores REST API.", ) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/resources/user.py ================================================ import os import requests from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema blp = Blueprint("Users", "users", description="Operations on users") def send_simple_message(to, subject, body): domain = os.getenv("MAILGUN_DOMAIN") return requests.post( f"https://api.mailgun.net/v3/{domain}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.query.filter(UserModel.username == user_data["username"]).first(): abort(409, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/02_send_email_user_registration/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/03_what_is_task_queue/README.md ================================================ --- ctslug: what-is-a-task-queue --- # What is a task queue? A queue is a data structure to which you can add and remove data, but a key aspect of it is that when you want to remove a piece of data from it, the piece of data removed is the first piece of data that was added. ![New elements are added at the end, called pushing, and removed from the start, called popping, of a queue](https://res.cloudinary.com/teclado/image/upload/v1689180793/courses/rest-apis-flask-python/queues.drawio_yqgtvg.png) This is identical to how people queuing works. The first person to arrive at the queue (i.e. the first in line), is the first person removed from the queue when they reach the ticket counter. We need a queueing system for our email sending so that when we offload tasks, we put them in a queue. Then we will have a separate program (the **background worker**), taking items from the queue one at a time and processing them. Each item in the queue will be an email to be sent (or rather, information so that the background worker can send the email). ## Setting up the Redis database for our queue We can use the Redis database to store our queue of tasks. There are alternative options, such as RabbitMQ, but we won't cover them in this course. You can install Redis in a few different ways: - Install it locally by following their guides. - Install it using Docker (I recommend this for a local install). - Use a Redis database in the cloud so you don't have to install anything (this is what we do in the video). Render.com can provide us with a free Redis database, so I recommend using that to get started. Navigate to your Render.com dashboard, and create a new free Redis database. The free Redis provided doesn't have persistence enabled, but that's okay. It means we will lose data if the service is turned off, but since we're using it as a task queue that's not as big a deal as it otherwise could be. Later on if we want, we can upgrade to one of the paid plans. To be able to add tasks to the queue from your dev environment, make sure to [allow external connections](https://render.com/docs/redis#connecting-to-your-redis-from-outside-render) in your Redis database configuration. ![Screenshot showing 0.0.0.0/0 as an allowed IP address when connecting to our Render Redis database](https://res.cloudinary.com/teclado/image/upload/v1689180794/courses/rest-apis-flask-python/render-redis-allowing-outside_tkxsls.png) You should get a Redis URL that looks like this: `rediss://red-ct8aen0hkl10:MnLs0mmrX7MBXWRkdrh49@frankfurt-redis.render.com:6379`. Save it, for we'll need it in the next lecture! ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/README.md ================================================ --- ctslug: populating-consuming-the-task-queue-with-rq --- # Populating and consuming the task queue with rq We'll be using the [`rq` library](https://python-rq.org/) for our task queue implementation. Another popular option is `celery`, which is substantially more complex. For most workloads, `rq` is sufficient and it's much easier to work with. First install the library: ```bash pip install rq ``` And remember to add it to your `requirements.txt` ```text title="requirements.txt" rq ``` Then it's helpful if we move the task code out to a separate file. Let's take our `send_simple_message` function and move it to `tasks.py`: ```py title="tasks.py" import os import requests from dotenv import load_dotenv load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") def send_simple_message(to, subject, body): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={"from": f"Your Name ", "to": [to], "subject": subject, "text": body} ) ``` Here I moved the domain line outside the function so it only runs once, and I've made sure to run `load_dotenv()` before it is requested. The background worker will import `tasks.py` once at the start of its lifetime, so doing this will (very slightly) improve performance. We could leave it like this, but I think we can do better. Let's write another function underneath that one that specifically describes the task that we want to perform in the background: send a registration email to a specific user: ```py title="tasks.py" import os import requests from dotenv import load_dotenv load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") def send_simple_message(to, subject, body): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={"from": f"Your Name ", "to": [to], "subject": subject, "text": body} ) # highlight-start def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", ) # highlight-end ``` :::tip Remember to change "Your Name" in `from` to whatever name you want your emails to come from! ::: Next up, add the Redis connection string that we got in the [previous section](../what_is_task_queue) to the `.env` file: ```text title=".env" REDIS_URL="" ``` And then let's go to our User resource and add a couple of imports: ```py title="resources/user.py" import redis from rq import Queue from tasks import send_user_registration_email ``` Then let's connect to Redis and create our `rq` queue. Under the blueprint definition, I'll add these lines: ```py title="resources/user.py" connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) ``` Now we can use the `queue` to "enqueue" jobs, i.e. add to the queue. That will put some data into the Redis database, which then the background worker can consume. ### How to enqueue a job using `rq` This is the easy part! We are going to remove the code that sends the email from `resources/user.py`, and instead enqueue it using the `queue` variable. This takes the name of the function we want the background worker to call, and then all the arguments we'd like to pass to that function when it runs. ```diff title="resources/user.py" -send_simple_message( - to=user.email, - subject="Successfully signed up", - body=f"Hi {user.username}! You have successfully signed up to the Stores REST API." -) +queue.enqueue(send_user_registration_email, user.email, user.username) ``` :::info Remember the `send_user_registration_email` function doesn't run when we call `.enqueue`. It runs when the background worker starts working on this task, which could take some time! ::: ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests redis rq ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/resources/user.py ================================================ import os import redis from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from rq import Queue from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema from tasks import send_user_registration_email blp = Blueprint("Users", "users", description="Operations on users") connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() queue.enqueue(send_user_registration_email, user.email, user.username) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/end/tasks.py ================================================ import os import requests from dotenv import load_dotenv load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") def send_simple_message(to, subject, body): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", ) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/resources/user.py ================================================ import os import requests from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema blp = Blueprint("Users", "users", description="Operations on users") def send_simple_message(to, subject, body): domain = os.getenv("MAILGUN_DOMAIN") return requests.post( f"https://api.mailgun.net/v3/{domain}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() send_simple_message( to=user.email, subject="Successfully signed up", body=f"Hi {user.username}! You have successfully signed up to the Stores REST API.", ) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/04_populate_rq_task_queue/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/README.md ================================================ --- ctslug: process-background-tasks-with-the-rq-worker --- # Process background tasks with the rq worker We've got our queue and we've added tasks to it, but they won't run until we start consuming them and popping them off the queue. To do this, we'll run a background worker whose job it is to pop items off the queue one at a time, and run the associated Python function with the associated arguments. :::tip MacOS or Linux? If you are using MacOS or Linux, you can run the background worker for testing using this command (make sure your virtual environment is active): ```bash rq worker -u emails ``` The `rq` executable is available after installing the `rq` library with `pip`. The `-u` flag gives it the Redis URL to connect to. The `emails` at the end is the name of the queue that it should consume from. Make sure it matches the name of the queue you defined in `resources/user.py`. ::: :::warning Running on MacOS You may get an error when running `rq worker` directly using MacOS (without Docker): ```text objc[21400]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called. ``` If so, try running this command before starting your `rq worker`: ```bash export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES ``` ::: The most reliable way to run the worker though, is using Docker. We are already used to running our API using Docker, so now we can use the same Docker image to run our worker. First, build the image: ```bash docker build -t rest-apis-flask-smorest-rq . ``` Then run a container, but instead of running the default entrypoint (defined by the `CMD` line in the `Dockerfile`), we'll tell it to run the `rq` program: ```bash docker run -w /app rest-apis-flask-smorest-rq sh -c "rq worker -u emails" ``` This ensures one of the [considerations](https://python-rq.org/docs/#considerations-for-jobs) that the `rq` documentation suggests: that the worker and the work generator (our API) share _exactly_ the same source code. Run another Docker container for your API, and try to register! import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
```bash docker run -p 5000:5000 rest-apis-flask-smorest-rq sh -c "flask run --host 0.0.0.0" ``` ```bash docker run -w /app rest-apis-flask-smorest-rq sh -c "rq worker -u emails" ``` :::info Make sure to enter your own Redis connection string in that command! :::
================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests redis rq ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/resources/user.py ================================================ import os import redis from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from rq import Queue from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema from tasks import send_user_registration_email blp = Blueprint("Users", "users", description="Operations on users") connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() queue.enqueue(send_user_registration_email, user.email, user.username) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/end/tasks.py ================================================ import os import requests from dotenv import load_dotenv load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") def send_simple_message(to, subject, body): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", ) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests redis rq ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/resources/user.py ================================================ import os import redis from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from rq import Queue from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema from tasks import send_user_registration_email blp = Blueprint("Users", "users", description="Operations on users") connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() queue.enqueue(send_user_registration_email, user.email, user.username) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/05_rq_background_worker/start/tasks.py ================================================ import os import requests from dotenv import load_dotenv load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") def send_simple_message(to, subject, body): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", ) ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/README.md ================================================ --- ctslug: sending-html-emails-with-mailgun --- # Sending HTML emails with Mailgun Until now, we've been sending exclusively text emails. These have a clear advantage: text is simple! They'll look the same in every email client and device, and for many things, text is good enough. However, I'll be the first to say that it doesn't look amazing. You're at the mercy of the default font family and size of the recipient's email client, and you can't personalize the email with your business branding. This is where HTML emails come into play. HTML emails require that we write HTML instead of text, and also CSS for the styling. We should still keep the text version of the email, just in case the recipient's email client doesn't render HTML for whatever reason. ## Writing HTML emails Crafting HTML emails is difficult! Every email client renders things slightly differently and supports different versions of the HTML and CSS specs. For example, it's discouraged to use CSS Flex when writing emails, because many email clients don't support it. That's why you'll see most HTML emails use HTML tables for their layout 🤮 Fortunately for us, Mailgun provides a few [HTML templates](https://www.mailgun.com/blog/email/transactional-html-email-templates/) that we can simply copy, paste, and modify. They test these HTML templates to make sure they render correctly in most email clients, and they come with CSS already written. ## Getting the Mailgun HTML email templates This link has a writeup of how HTML templates work: [https://www.mailgun.com/blog/email/transactional-html-email-templates/](https://www.mailgun.com/blog/email/transactional-html-email-templates/). You can find their templates here: [https://github.com/mailgun/transactional-email-templates/tree/master/templates/inlined](https://github.com/mailgun/transactional-email-templates/tree/master/templates/inlined). There are three different transactional email templates, and we'll be using the [`action.html`](https://raw.githubusercontent.com/mailgun/transactional-email-templates/master/templates/inlined/action.html) template in this lecture for our "user registration" email. ## Adding the template to our application Create a `templates/email/action.html` file in your project, and place the entire raw code of the `action.html` file from the Mailgun repository. :::tip Make sure to grab the [**raw** code](https://raw.githubusercontent.com/mailgun/transactional-email-templates/master/templates/inlined/action.html) to make sure there are no GitHub artefacts in the code. ::: The copied [`action.html`](https://github.com/mailgun/transactional-email-templates/blob/master/templates/inlined/action.html) code from the [Mailgun repository](https://github.com/mailgun/transactional-email-templates) (below) is licensed with the MIT license. Please see the [repository license](https://github.com/mailgun/transactional-email-templates/blob/master/LICENSE) for more information. ```html title="templates/email/action.html" Actionable emails e.g. reset password
Please confirm your email address by clicking the link below.
We may need to send you critical information about our service and it is important that we have an accurate email address.
— The Mailgunners
``` Now we can easily modify this file to suit our needs. Here are the changes I'll make: ```diff title="templates/email/action.html" -Actionable emails e.g. reset password +Welcome to Stores REST API
Welcome to the Stores REST API.
Your account with username {{ username }} has been created successfully.
— Stores REST API
================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/end/templates/email/action.original.html ================================================ Actionable emails e.g. reset password
Please confirm your email address by clicking the link below.
We may need to send you critical information about our service and it is important that we have an accurate email address.
— The Mailgunners
================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests redis rq ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/resources/user.py ================================================ import os import redis from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from rq import Queue from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema from tasks import send_user_registration_email blp = Blueprint("Users", "users", description="Operations on users") connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() queue.enqueue(send_user_registration_email, user.email, user.username) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/06_sending_html_emails/start/tasks.py ================================================ import os import requests from dotenv import load_dotenv load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") def send_simple_message(to, subject, body): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Your Name ", "to": [to], "subject": subject, "text": body, }, ) def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", ) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/README.md ================================================ --- ctslug: deploy-the-rq-background-worker-to-render-com --- # Deploy the rq background worker to Render.com When deploying to Render.com, it's much easier if we don't have to pass the `REDIS_URL` and the queue name directly to the command. So instead, let's create a `settings.py` file and put our `rq` worker configuration there: ```python title="settings.py" import os from dotenv import load_dotenv load_dotenv() REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") QUEUES = ["emails", "default"] ``` The names of the variables are important, see [the documentation](https://python-rq.org/docs/workers/#using-a-config-file) for all the options that are currently supported. To run the `rq` worker using this settings file use `rq worker -c settings`. Let's add this to our repo, and then deploy the background worker to Render.com. First create a new background worker: ![Create a new service of type background worker in Render.com](https://res.cloudinary.com/teclado/image/upload/v1689180804/courses/rest-apis-flask-python/render-create-bg-worker_agtqej.png) Then, give it a name and fill in its basic settings. The default works for the most part. Make sure it's in the same region as or close to your Postgres and Redis databases: ![Filling in the Render basic worker information with its name set to 'rest-api-background-worker', environment set to 'docker', and region set to 'Frankfurt'](https://res.cloudinary.com/teclado/image/upload/v1689180803/courses/rest-apis-flask-python/render-bg-worker-basic-settings_tu8vkz.png) Add the environment variables it needs. Although in this case it doesn't need the `DATABASE_URL`, you can add it if you will be adding other tasks that do use the database in the near future. If not, leave it out. :::warning Internal URL If your Redis database is with Render.com, you'd want to use the Redis database **Internal URL**, but I encountered some issues with it where the `redis` package didn't recognise the URL. Try it, but fall back to the external URL if it doesn't work. ::: ![Environment variables added in Render.com including DATABASE_URL, REDIS_URL, MAILGUN_API_KEY, and MAILGUN_DOMAIN, with their respective values](https://res.cloudinary.com/teclado/image/upload/v1689180803/courses/rest-apis-flask-python/render-bg-worker-env-vars_zmgmwp.png) Finally, this "background worker" is just a Python program without networking capabilities. So if we leave it as is, it will actually just run our Dockerfile and the Dockerfile's `CMD` command (which starts our web application). Therefore we want to give it a custom Docker command that starts the background worker. In that command, I'll go into the `/app` directory of the Docker container, and run the `rq` worker passing in the `settings.py` file. The command is `/bin/bash -c cd /app && rq worker -c settings`. This is what it looks like in Render.com: ![Screenshot showing the Docker command in Render.com](https://res.cloudinary.com/teclado/image/upload/v1689180803/courses/rest-apis-flask-python/render-bg-worker-docker-command_jezho0.png) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/.python-version ================================================ 3.10.6 ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests redis rq ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/resources/user.py ================================================ import os import redis from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from rq import Queue from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema from tasks import send_user_registration_email blp = Blueprint("Users", "users", description="Operations on users") connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() queue.enqueue(send_user_registration_email, user.email, user.username) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/settings.py ================================================ import os from dotenv import load_dotenv load_dotenv() REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") QUEUES = ["emails", "default"] ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/tasks.py ================================================ import os import requests from dotenv import load_dotenv import jinja2 load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") template_loader = jinja2.FileSystemLoader("templates") template_env = jinja2.Environment(loader=template_loader) def render_template(template_filename, **context): return template_env.get_template(template_filename).render(**context) def send_simple_message(to, subject, body, html): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Jose Salvatierra ", "to": [to], "subject": subject, "text": body, "html": html, }, ) def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", render_template("email/action.html", username=username), ) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/action.html ================================================ Welcome to Stores REST API
Welcome to the Stores REST API.
Your account with username {{ username }} has been created successfully.
— Stores REST API
================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/end/templates/email/action.original.html ================================================ Actionable emails e.g. reset password
Please confirm your email address by clicking the link below.
We may need to send you critical information about our service and it is important that we have an accurate email address.
— The Mailgunners
================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.gitignore ================================================ .env .venv .vscode __pycache__ data.db *.pyc .DS_Store ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/.python-version ================================================ 3.10.6 ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/Dockerfile ================================================ FROM python:3.10 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["/bin/bash", "docker-entrypoint.sh"] ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/README.md ================================================ # REST APIs Recording Project Nothing here yet! ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/app.py ================================================ import os from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from flask_migrate import Migrate from dotenv import load_dotenv from db import db from blocklist import BLOCKLIST from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint from resources.user import blp as UserBlueprint def create_app(db_url=None): app = Flask(__name__) load_dotenv() app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or os.getenv("DATABASE_URL", "sqlite:///data.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) migrate = Migrate(app, db) api = Api(app) app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( { "description": "The token is not fresh.", "error": "fresh_token_required", } ), 401, ) @jwt.additional_claims_loader def add_claims_to_jwt(identity): # Look in the database and see whether the user is an admin if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return ( jsonify({"message": "The token has expired.", "error": "token_expired"}), 401, ) @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) api.register_blueprint(UserBlueprint) return app ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/blocklist.py ================================================ """ blocklist.py This file just contains the blocklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blocklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/docker-entrypoint.sh ================================================ #!/bin/sh flask db upgrade exec gunicorn --bind 0.0.0.0:80 "app:create_app()" ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/07006e31e788_.py ================================================ """empty message Revision ID: 07006e31e788 Revises: Create Date: 2022-08-15 12:44:59.705694 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '07006e31e788' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=80), nullable=False), sa.Column('password', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('username') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('users') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/8ca023a4a4b0_.py ================================================ """empty message Revision ID: 8ca023a4a4b0 Revises: 07006e31e788 Create Date: 2022-08-15 12:52:41.303543 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8ca023a4a4b0' down_revision = '07006e31e788' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/bb5da1e68550_.py ================================================ """empty message Revision ID: bb5da1e68550 Revises: 8ca023a4a4b0 Create Date: 2022-08-29 13:06:57.697368 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'bb5da1e68550' down_revision = '8ca023a4a4b0' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('items', 'price', existing_type=sa.REAL(), type_=sa.Float(precision=2), existing_nullable=False) op.alter_column('users', 'password', existing_type=sa.VARCHAR(length=80), type_=sa.String(length=256), existing_nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.alter_column('users', 'password', existing_type=sa.String(length=256), type_=sa.VARCHAR(length=80), existing_nullable=False) op.alter_column('items', 'price', existing_type=sa.Float(precision=2), type_=sa.REAL(), existing_nullable=False) # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/migrations/versions/d8e0f80631fb_.py ================================================ """empty message Revision ID: d8e0f80631fb Revises: bb5da1e68550 Create Date: 2022-10-11 14:46:28.100282 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = "d8e0f80631fb" down_revision = "bb5da1e68550" branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column("users", sa.Column("email", sa.String(), nullable=False)) op.create_unique_constraint("email", "users", ["email"]) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("email", "users", type_="unique") op.drop_column("users", "email") # ### end Alembic commands ### ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/__init__.py ================================================ from models.store import StoreModel from models.item import ItemModel from models.tag import TagModel from models.item_tags import ItemTags from models.user import UserModel ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item.py ================================================ from sqlalchemy import ForeignKey from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/item_tags.py ================================================ from db import db class ItemTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String(256), nullable=False) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/requirements.txt ================================================ flask==2.3.2 flask-smorest python-dotenv sqlalchemy flask-sqlalchemy flask-jwt-extended passlib flask-migrate gunicorn psycopg2 requests redis rq ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item @jwt_required() def delete(self, item_id): jwt = get_jwt() if not jwt.get("is_admin"): abort(401, message="Admin privilege required.") item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred whilte inserting the item.") return item ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/store.py ================================================ import uuid from flask import request from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"} @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(200, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e) ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."} ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted." ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", ) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/resources/user.py ================================================ import os import redis from flask.views import MethodView from flask_smorest import Blueprint, abort from passlib.hash import pbkdf2_sha256 from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, jwt_required, get_jwt, ) from rq import Queue from sqlalchemy import or_ from db import db from blocklist import BLOCKLIST from models import UserModel from schemas import UserSchema, UserRegisterSchema from tasks import send_user_registration_email blp = Blueprint("Users", "users", description="Operations on users") connection = redis.from_url( os.getenv("REDIS_URL") ) # Get this from Render.com or run in Docker queue = Queue("emails", connection=connection) @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserRegisterSchema) def post(self, user_data): if UserModel.query.filter( or_( UserModel.username == user_data["username"], UserModel.email == user_data["email"], ) ).first(): abort(409, message="A user with that username or email already exists.") user = UserModel( username=user_data["username"], email=user_data["email"], password=pbkdf2_sha256.hash(user_data["password"]), ) db.session.add(user) db.session.commit() queue.enqueue(send_user_registration_email, user.email, user.username) return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.query.filter( UserModel.username == user_data["username"] ).first() if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(identity=str(user.id)) return {"access_token": access_token, "refresh_token": refresh_token} abort(401, message="Invalid credentials.") @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token} @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out."} @blp.route("/user/") class User(MethodView): @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.query.get_or_404(user_id) return user def delete(self, user_id): user = UserModel.query.get_or_404(user_id) db.session.delete(user) db.session.commit() return {"message": "User deleted."}, 200 ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() store_id = fields.Int() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int(dump_only=True) username = fields.Str(required=True) password = fields.Str(required=True) class UserRegisterSchema(UserSchema): email = fields.Str(required=True) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/tasks.py ================================================ import os import requests from dotenv import load_dotenv import jinja2 load_dotenv() DOMAIN = os.getenv("MAILGUN_DOMAIN") template_loader = jinja2.FileSystemLoader("templates") template_env = jinja2.Environment(loader=template_loader) def render_template(template_filename, **context): return template_env.get_template(template_filename).render(**context) def send_simple_message(to, subject, body, html): return requests.post( f"https://api.mailgun.net/v3/{DOMAIN}/messages", auth=("api", os.getenv("MAILGUN_API_KEY")), data={ "from": f"Jose Salvatierra ", "to": [to], "subject": subject, "text": body, "html": html, }, ) def send_user_registration_email(email, username): return send_simple_message( email, "Successfully signed up", f"Hi {username}! You have successfully signed up to the Stores REST API.", render_template("email/action.html", username=username), ) ================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/action.html ================================================ Welcome to Stores REST API
Welcome to the Stores REST API.
Your account with username {{ username }} has been created successfully.
— Stores REST API
================================================ FILE: docs/docs/12_task_queues_emails/07_deploy_background_worker_render/start/templates/email/action.original.html ================================================ Actionable emails e.g. reset password
Please confirm your email address by clicking the link below.
We may need to send you critical information about our service and it is important that we have an accurate email address.
— The Mailgunners
================================================ FILE: docs/docs/12_task_queues_emails/Insomnia_section12.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-18T22:12:58.901Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_349d1cc5283848fb92a4911bd38bebc6","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491718,"created":1668809491718,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_86d10d221e114aa2be9542daf31009ec","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491715,"created":1668809491715,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"wrk_3626a93c829b4cf683cbf49c61bdaaef","parentId":null,"modified":1668809491665,"created":1668809491665,"name":"Section 12","description":"","scope":"collection","_type":"workspace"},{"_id":"req_94b040afec0a43e098320c3b03d5f0d7","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491725,"created":1668809491725,"url":"{{url}}/refresh","name":"/refresh Get non-fresh token","description":"","method":"POST","body":{},"parameters":[],"headers":[{"id":"pair_d002eea6b22e4190909fc8366b5a4704","name":"Authorization","value":"Bearer {{refresh_token}}","description":""}],"authentication":{},"metaSortKey":-1667328634100.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ea98179fc95645199746896edbe91c33","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809520188,"created":1668809491716,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"email\": \"example@gmail.com\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_975ec20d9bec44538591da08c95ccd63"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82977ddde2e14e7c8ca337842483a5f2","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491722,"created":1668809491722,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d0cb7e79bbfd4e97a88ef48ab0ddab14","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491723,"created":1668809491723,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_88445b44c1074ac091314fa911a6e0cf","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491720,"created":1668809491720,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_23333e08fd834798b6cb5ae3fb9a4831","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491705,"created":1668809491705,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_a2feddc6ef5e44a495722d631fb86a4e","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491701,"created":1668809491701,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_fbda11a32a194f8986c6a09bf6368f71","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491703,"created":1668809491703,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4cf6d863192347d2beb9738fffc06aa6","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491708,"created":1668809491708,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f08a251169c46e8bbe9aa3b9ef34069","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491706,"created":1668809491706,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fed862a14dfb4fbca5c16a361e9337b2","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491710,"created":1668809491710,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_2ebdc85b06354c5c8a2ac2a12b9f74d6","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491711,"created":1668809491711,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84fcf23f2c94480db2771b7fdd981998","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491679,"created":1668809491679,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_020cd4f64dbc4a31921e85c95a49d633","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491678,"created":1668809491678,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"req_290cb9eb4d684279aa9735f946009e32","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491683,"created":1668809491683,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e691135e8b7840a188b9b8a6ffee6082","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491681,"created":1668809491681,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4b8b6a31400046d9af34c3420bcb5f1d","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491685,"created":1668809491685,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_eea8d6e13fcb44ca99f683f25e9d7e9f","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491694,"created":1668809491694,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_23d1586241d44ecda90d772f973ee792","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491689,"created":1668809491689,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_732f07562b5849b6ae03ee7a327d34d1","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491699,"created":1668809491699,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e67af8716d3e46849f0632c6f9cc845f","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491690,"created":1668809491690,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0e44aa1615284e2b9469a5129ba11b9f","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491692,"created":1668809491692,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ce38744e96c24a63ac7cb0b14cfb72ec","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491697,"created":1668809491697,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_05389734f73848a3922232b339a3d34d","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491667,"created":1668809491667,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005","access_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'when-expired', 300 %}","refresh_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5yZWZyZXNoX3Rva2Vu::46b', 'when-expired', NaN %}"},"dataPropertyOrder":{"&":["url","access_token","refresh_token"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_a60cc8f7a1dc4acfa31828ea71765d5e","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491673,"created":1668809491673,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_b684ea4fa67c4d13befbc1e6ee30fdb6","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491739,"created":1668809491675,"fileName":"Section 12","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docs/12_task_queues_emails/_category_.json ================================================ { "label": "Task queues with rq and e-mail sending", "position": 12 } ================================================ FILE: docs/docs/Insomnia_all_sections.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-11-18T22:13:36.391Z","__export_source":"insomnia.desktop.app:v2022.6.0","resources":[{"_id":"req_9451df3aae714e93a8ed529b3a1f99c2","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666124555354,"created":1666122990495,"url":"http://127.0.0.1:5000/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423031,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_8b9c03412d0e463fabe784d205f1d604","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666124528874,"created":1666124528874,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528874,"_type":"request_group"},{"_id":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","parentId":null,"modified":1666991857781,"created":1666122928011,"name":"Section 3","description":"","scope":"collection","_type":"workspace"},{"_id":"req_0a9c4822679b4eae92dec7432fe144b8","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900810115,"created":1666123651275,"url":"http://127.0.0.1:5000/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store3\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124422881,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e15dafc098ac4a2198304d2aead2a5b9","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900815265,"created":1666123912423,"url":"http://127.0.0.1:5000/store/My Store/item","name":"/store//item Create item in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Table\",\n\t\"price\": 17.99\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423081,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6363c8d4deb74b5bbccb1e2105277dac","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900812784,"created":1666124168137,"url":"http://127.0.0.1:5000/store/My store3","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124422956,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_697ca0714a3d4e94819411e3df0a2a17","parentId":"fld_8b9c03412d0e463fabe784d205f1d604","modified":1666900846590,"created":1666124316888,"url":"http://127.0.0.1:5000/store/My store3/item","name":"/store//item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423056,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_3d2b5cd58a4b4a6983c133118c5f8027","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666125193227,"created":1666124761134,"url":"http://127.0.0.1:5000/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_afac4dd2683746c586c6ff61228611de","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666125229064,"created":1666124761133,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","parentId":null,"modified":1666991873213,"created":1666124761123,"name":"Section 5 before Docker","description":"","scope":"collection","_type":"workspace"},{"_id":"req_b9dafd45675e4c478fa4dd125f4827b3","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666902941803,"created":1666124761136,"url":"http://127.0.0.1:5000/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_bd3ecff11e5b49baa489812528235afb","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666902781180,"created":1666124761139,"url":"http://127.0.0.1:5000/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_34cbd59313d44bbfa4fd70166e341b05","parentId":"fld_afac4dd2683746c586c6ff61228611de","modified":1666902749338,"created":1666124977832,"url":"http://127.0.0.1:5000/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d48cf679c2664c9bb566b600634b966f","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666902939274,"created":1666124761145,"url":"http://127.0.0.1:5000/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": \"f48f94a4760e40d39debf155396a9dec\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_91ec9103821245f69f82aa78362f81e1","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666125224286,"created":1666124761144,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_8982d9bcce734f60a9f27a8eb1fc748c","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666125332019,"created":1666124928966,"url":"http://127.0.0.1:5000/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_637d0fb6ba9d4c25b6ad9f5bdda73036","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666902961406,"created":1666125038450,"url":"http://127.0.0.1:5000/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_99fbb0c34cd049f1bb8ac4e944f0ae6d","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666902838552,"created":1666125104208,"url":"http://127.0.0.1:5000/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e581f2420345418c84d71dbed226b6da","parentId":"fld_91ec9103821245f69f82aa78362f81e1","modified":1666125710431,"created":1666125184534,"url":"http://127.0.0.1:5000/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_08302ba35f784bdc9fa2edc0cb080287","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666985452213,"created":1666905719010,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_0bc4d91251f54e1d8e00966a259b35bc","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719008,"created":1666905719008,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_e6c8aab80c134d35810fd37d43cce51e","parentId":null,"modified":1666991880304,"created":1666905718998,"name":"Section 5 - Docker","description":"","scope":"collection","_type":"workspace"},{"_id":"req_0c240b23280746a6a1a56d7644fb89ce","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666987464108,"created":1666905719011,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6fdedbe47a9941af9b8459816f179274","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666985886605,"created":1666905719013,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fc255f6789fe45ed80b2ef83e6bb6645","parentId":"fld_0bc4d91251f54e1d8e00966a259b35bc","modified":1666985462540,"created":1666905719014,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_3d189bf5d88349e3bce363a420407f65","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666987468265,"created":1666905719018,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": \"8efca659f8674c56b5cd035ecc0d42ec\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_80dee5df10c347198d8f12d85703d582","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719016,"created":1666905719016,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_35d865c76bce4e1b9c378d82ece413f7","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666985474126,"created":1666905719019,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b42e3c4d855a433394ac1a8a60c2b91b","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666985467338,"created":1666905719020,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c2bf495d5cbb49d8b933b832a717662a","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666987071504,"created":1666905719022,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9a89b2ecfc61457d8cac15985597c0a0","parentId":"fld_80dee5df10c347198d8f12d85703d582","modified":1666986841489,"created":1666905719023,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8612530e54144a039af84006ee8c882d","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666987689179,"created":1666987689179,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_7ed8d16fd87545519f2f64b2613ea84a","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689178,"created":1666987689178,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_a6cd641e98494bca9a11fe77b66c7e37","parentId":null,"modified":1666987689171,"created":1666987689171,"name":"Section 6","description":"","scope":"collection","_type":"workspace"},{"_id":"req_4f7b9d616b0e44ca94ca51cc71660da0","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666990320166,"created":1666987689181,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_335002433e9745068d074f1f942ddde2","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666987689183,"created":1666987689183,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9228903cf7a54601a51a59f6a6692363","parentId":"fld_7ed8d16fd87545519f2f64b2613ea84a","modified":1666987689184,"created":1666987689184,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0624b67ef6b841f482b7e7522fb6f405","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666990328367,"created":1666987689187,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_8761c7b0aa5142cba8985868cbda3de2","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689186,"created":1666987689186,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_b2651043ea5e4b33b073f260712fb114","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689189,"created":1666987689189,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d1d499ead63e469ca04571899cc4759f","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689190,"created":1666987689190,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_640f313dbd5a4bfcbf98081e2fab6d4a","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689192,"created":1666987689192,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94738c7e8c774bd597ffe97bf7b921b6","parentId":"fld_8761c7b0aa5142cba8985868cbda3de2","modified":1666987689194,"created":1666987689194,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ab3c728a796e4b4ca51803248e1b0650","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666990745596,"created":1666990745596,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_597937a09435404ebe2200cbaeed101d","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745596,"created":1666990745596,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_6efa5c8b8fa142a28f436b209fba66fa","parentId":null,"modified":1666990745588,"created":1666990745588,"name":"Section 7","description":"","scope":"collection","_type":"workspace"},{"_id":"req_8a36225a08bb4dfbbf98fd983b0d4a5f","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666991654175,"created":1666990745599,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store2\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_a9d43bb23e1246da94aec50b9b9ca652","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666990745601,"created":1666990745601,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_303507538c0f408eb6d91784b7ed8d36","parentId":"fld_597937a09435404ebe2200cbaeed101d","modified":1666990745602,"created":1666990745602,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6c72c92f81924ce7bc26ceb488fd64ff","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666991658886,"created":1666990745605,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chair\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_baa111a1ff5849b4838637f09844bfde","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745604,"created":1666990745604,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_e86e0877045640d690454a99b176f3a2","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745607,"created":1666990745607,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f4776751aecc4c6eafb264dc2d2c24cb","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745609,"created":1666990745609,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f0c4a3d747a543249131e19ceea79e56","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745610,"created":1666990745610,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e6bc2422c8cf4f119c7dc10251a9af65","parentId":"fld_baa111a1ff5849b4838637f09844bfde","modified":1666990745611,"created":1666990745611,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_85adfd198935497bb7aedb266beb5bf3","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991788350,"created":1666990945502,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_86b5e8072a894c409febe46716e99809","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990939045,"created":1666990939045,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_379d0e42420f466bbad1b7481e5e7816","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991794866,"created":1666990973919,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f07aab6ead044ca7bba0de3437ab08c4","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991779049,"created":1666991031108,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4765f7ca8e1e46308cdde255d09a2ffc","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991810641,"created":1666991378432,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_77d1a5f225c54acbb27bac15010722ad","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991824192,"created":1666991489163,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d60510ab22b2499abb20a63629e30fcd","parentId":"fld_86b5e8072a894c409febe46716e99809","modified":1666991828682,"created":1666991524256,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_2ba32c3564f3456aa1c8731323508968","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1666991842400,"created":1666991842400,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_a18660fe822f44828b330ba9a4d3946a","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842399,"created":1666991842399,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_101d091b922e44c28f66528e9ef2ae37","parentId":null,"modified":1666991842388,"created":1666991842388,"name":"Section 8","description":"","scope":"collection","_type":"workspace"},{"_id":"req_57daa411077044a98d3b2534ee735703","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1667332324728,"created":1666991842402,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_047bf72b98214de4a8f711fd46b73eb4","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1666991842403,"created":1666991842403,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6c1af0972dbe4faebf5973d95670c241","parentId":"fld_a18660fe822f44828b330ba9a4d3946a","modified":1667332341585,"created":1666991842405,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_32c5d69aec8f44fdb33852b456c99b7a","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332259971,"created":1666991842408,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842407,"created":1666991842407,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_1843f2db017842b993251abbedeb2e8b","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332298429,"created":1666991842410,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0d1c09067c6a419a9096b32da16d01a3","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332272845,"created":1666991842412,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d311f219368d46a6b6d92be142a32763","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332311745,"created":1666991842413,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_dbdfc5218bf54bfab39db152b3ffe982","parentId":"fld_b2b78ef1cd504663bef29f23cb3fd9a7","modified":1667332288041,"created":1666991842414,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjA3MCwianRpIjoiODA5Njk2MmUtYmNlMy00NjgyLWJkZTUtYjYzMDgwZGU3NmJjIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyMDcwLCJleHAiOjE2NjczMzI5NzB9.MY-MKMh8mEaX8cWjz0RJZ2JStD7hHBD1j7nB3GT2vks","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f613fc18d27648daa00d6b78deea5b66","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842418,"created":1666991842418,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_815efb55c04548dea5dfec2e2f69ebcb","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842417,"created":1666991842417,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_362ff303b1054bd0b0d62522803aea64","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842419,"created":1666991842419,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_266cac65472a4a118929460e58893fb0","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842421,"created":1666991842421,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e899763b1c0a46ad9eb47f9e628aa643","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842422,"created":1666991842422,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e63e0532c2c340aa8ed6643f0ae1b4ec","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842424,"created":1666991842424,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5c6887cfb9c94dc2aa2fe76d525fecac","parentId":"fld_815efb55c04548dea5dfec2e2f69ebcb","modified":1666991842426,"created":1666991842426,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fe639dc55a2d439f9aa7a6bceee6a9fa","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667332404117,"created":1667328597818,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_3426c101e9094fa79fbf96cf1ec23fae","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1667328596182,"created":1667328596182,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"req_adb596c3d0ee48e2b009a555297f36ac","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667331428779,"created":1667328670383,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_953d7fdcaf1f433b9b3b0cdf6453b0cd","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667332353929,"created":1667328704142,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5e7ca3df73314f8c839493d53528760b","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1667332406439,"created":1667331560707,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_bc66397203734df4b4eb6d153d8d3ec3","parentId":"fld_3426c101e9094fa79fbf96cf1ec23fae","modified":1668007854634,"created":1667332459073,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_22eb90da9d974ac2880b9207d6d11c01","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566845,"created":1667332566845,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_6315121566934d098d5f963bb2b25679","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566844,"created":1667332566844,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","parentId":null,"modified":1667332566837,"created":1667332566837,"name":"Section 8 - Chaining","description":"","scope":"collection","_type":"workspace"},{"_id":"req_3efb83da0cb34711961a59b6b3b39278","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566848,"created":1667332566848,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_44b8b847e3bd4b35bb7c5d2df5e0ee94","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566849,"created":1667332566849,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_24669eae6530416094eb2c746ae577ed","parentId":"fld_6315121566934d098d5f963bb2b25679","modified":1667332566851,"created":1667332566851,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69cc615e910a4ca7a91382b455c3ceb5","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332840222,"created":1667332566854,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_68ade160b31f446995815fec3dc30a93","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566853,"created":1667332566853,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_310092462fe44643bdbcf671dd488033","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332838232,"created":1667332566855,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_41e02aac89e5447fb70b42a5fbb6e42d","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667650833785,"created":1667332566857,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_260395e8e71c47559f2c70f50618ea38","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332566858,"created":1667332566858,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_51ea1a9b34584746a8368ede6f6bfadf","parentId":"fld_68ade160b31f446995815fec3dc30a93","modified":1667332848385,"created":1667332566859,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e2bc3c7dd1c240baad60b3881359bf38","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566862,"created":1667332566862,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_db9477d576514920b5a9e1d2d82d2254","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566861,"created":1667332566861,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_ba56440f8f204525a13fbdfbd2273ae0","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566864,"created":1667332566864,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9237adf4f3e74e1b9aa8dbb15680c2f3","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566866,"created":1667332566866,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ae7151238fe84a65b5b1064b87cef5bd","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566867,"created":1667332566867,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c11ab9d081754c3e95f0eed61219454a","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566868,"created":1667332566868,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_03468f66652a42dcbd34ef05ce4fd714","parentId":"fld_db9477d576514920b5a9e1d2d82d2254","modified":1667332566870,"created":1667332566870,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_252ddd492b9242ffb1a6fe21e25534c5","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667650976611,"created":1667332566872,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_975ec20d9bec44538591da08c95ccd63"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566872,"created":1667332566872,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"req_00cda5fbc60a4905bf5b8e67aa7dadb4","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667332566874,"created":1667332566874,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_229bc7e2601e44cb82eb3e5eafa90202","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667332566875,"created":1667332566875,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fe8e836ebd4a44cd9f37ff8738e7ed98","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667650979775,"created":1667332566877,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6a638b0dfd3040118a0282fe5a49b5fb","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667332566878,"created":1667332566878,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0ae766df1c25481e83bd6ade061a919b","parentId":"fld_f0dbcc5050af40dbaabdd9bfe69a098f","modified":1667676377948,"created":1667650753174,"url":"{{url}}/refresh","name":"/refresh Get non-fresh token","description":"","method":"POST","body":{},"parameters":[],"headers":[{"id":"pair_d002eea6b22e4190909fc8366b5a4704","name":"Authorization","value":"Bearer {{refresh_token}}","description":""}],"authentication":{},"metaSortKey":-1667328634100.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fe2a4b3d67664cd194efe09393b9fd02","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668007660660,"created":1668007660660,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_db95f405b201413889d2ae8918eee2d4","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660658,"created":1668007660658,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_f14186e0f24a4da9ab3ab8d628227a36","parentId":null,"modified":1668007660646,"created":1668007660646,"name":"Section 11","description":"","scope":"collection","_type":"workspace"},{"_id":"req_90015fc3fcae4301b0d8a5c0355a3d83","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668678375918,"created":1668007660661,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b34295f6d8b24000b77f812c9cc2c2cc","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668007660663,"created":1668007660663,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0e20a55b16034f06a3246c4e76f1c387","parentId":"fld_db95f405b201413889d2ae8918eee2d4","modified":1668007660665,"created":1668007660665,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b8cc399abc8749eea66c6967522f8a36","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668678378059,"created":1668007660669,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_11fd3ae720264f0b9480c6277f1a7633","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660668,"created":1668007660668,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_8e9e92854eab4e74af765725b54c7d31","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668007660671,"created":1668007660671,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_671694da515a4c4e877008b02cd17f57","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668007660673,"created":1668007660673,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5303d646b4c44843bc9c8322675f9c54","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668678378608,"created":1668007660675,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_a55050436885439f8393e70546d75641","parentId":"fld_11fd3ae720264f0b9480c6277f1a7633","modified":1668007660676,"created":1668007660676,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c063f1996fde409f81d62a0e3d20b631","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660681,"created":1668007660681,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_3083883073354f11bb6c2aa2fcbe76d3","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660679,"created":1668007660679,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_8b7c05ec34874814bbb1ed17f23d7ea3","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660682,"created":1668007660682,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5739a9e1be2e4fcd8ecdac5142b5e76d","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668678381399,"created":1668007660684,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_007ed54990944618a9e4bbf4fed9bdc8","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660686,"created":1668007660686,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fbc4905447b04feca058e6f8c0428acf","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660688,"created":1668007660688,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_839409cd69e945b694d46f3101eec0d5","parentId":"fld_3083883073354f11bb6c2aa2fcbe76d3","modified":1668007660690,"created":1668007660690,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_a9790bade2c14dd5a87b85088017aa92","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668809417998,"created":1668007660694,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_975ec20d9bec44538591da08c95ccd63"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_8173221215364faa9eaf4de28d0b227d","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660692,"created":1668007660692,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"req_92cec23efa0d47a582b4cf476fbe1c7d","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668007660695,"created":1668007660695,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_969a6a61d9b74bd3ad27623663999dca","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668007660697,"created":1668007660697,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0aa8a2f045e94cea96ba0f7061a26c41","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668678383934,"created":1668007660698,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1a5f0c9aa22841e0a60e47c6a5f65edc","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668678384453,"created":1668007660701,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_70c9c31ae27e47b684428bb88932f96d","parentId":"fld_8173221215364faa9eaf4de28d0b227d","modified":1668007660702,"created":1668007660702,"url":"{{url}}/refresh","name":"/refresh Get non-fresh token","description":"","method":"POST","body":{},"parameters":[],"headers":[{"id":"pair_d002eea6b22e4190909fc8366b5a4704","name":"Authorization","value":"Bearer {{refresh_token}}","description":""}],"authentication":{},"metaSortKey":-1667328634100.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84fcf23f2c94480db2771b7fdd981998","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491679,"created":1668809491679,"url":"{{url}}/store","name":"/store Get all store data","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423181,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_020cd4f64dbc4a31921e85c95a49d633","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491678,"created":1668809491678,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528974,"_type":"request_group"},{"_id":"wrk_3626a93c829b4cf683cbf49c61bdaaef","parentId":null,"modified":1668809491665,"created":1668809491665,"name":"Section 12","description":"","scope":"collection","_type":"workspace"},{"_id":"req_e691135e8b7840a188b9b8a6ffee6082","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491681,"created":1668809491681,"url":"{{url}}/store","name":"/store Create new store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"My store22\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_2f351442a28746c98995c18ea907feaa"}],"authentication":{},"metaSortKey":-1666124423143.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_290cb9eb4d684279aa9735f946009e32","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491683,"created":1668809491683,"url":"{{url}}/store/STORE_ID","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423156,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4b8b6a31400046d9af34c3420bcb5f1d","parentId":"fld_020cd4f64dbc4a31921e85c95a49d633","modified":1668809491685,"created":1668809491685,"url":"{{url}}/store/STORE_ID","name":"/store/ Delete store","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666124423131,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_e67af8716d3e46849f0632c6f9cc845f","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491690,"created":1668809491690,"url":"{{url}}/item","name":"/item Create item","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Chairs\",\n\t\"price\": 17.99,\n\t\"store_id\": 1\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_c60e217df8494bf1af1c02b872b5430a"},{"id":"pair_e6b6dbc1883d4ee1a90ac154e8fbb3a7","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104220.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_23d1586241d44ecda90d772f973ee792","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491689,"created":1668809491689,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666124528924,"_type":"request_group"},{"_id":"req_0e44aa1615284e2b9469a5129ba11b9f","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491692,"created":1668809491692,"url":"{{url}}/item/ITEM_ID","name":"/item/ Delete item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_1b62f1a1d23245439dfc1a356f67cd36","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104214.25,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_eea8d6e13fcb44ca99f683f25e9d7e9f","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491694,"created":1668809491694,"url":"{{url}}/item","name":"/item Get all items","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_d42eaef671e847b2a2eadb8f3af9be22","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104308,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ce38744e96c24a63ac7cb0b14cfb72ec","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491697,"created":1668809491697,"url":"{{url}}/item/ITEM_ID","name":"/item/ Update item","description":"","method":"PUT","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Another Chair\",\n\t\"price\": 20.00\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_4c9b7b960e3b499f8e94f507537195ae"}],"authentication":{},"metaSortKey":-1666125104208,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_732f07562b5849b6ae03ee7a327d34d1","parentId":"fld_23d1586241d44ecda90d772f973ee792","modified":1668809491699,"created":1668809491699,"url":"{{url}}/item/ITEM_ID","name":"/item/ Get item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"id":"pair_117512e4f7834d24ab18754279464356","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1666125104258,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fbda11a32a194f8986c6a09bf6368f71","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491703,"created":1668809491703,"url":"{{url}}/tag/TAG_ID","name":"/tags/ Get tag","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945502,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_a2feddc6ef5e44a495722d631fb86a4e","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491701,"created":1668809491701,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1666990939045,"_type":"request_group"},{"_id":"req_23333e08fd834798b6cb5ae3fb9a4831","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491705,"created":1668809491705,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Get tags in store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990973919,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f08a251169c46e8bbe9aa3b9ef34069","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491706,"created":1668809491706,"url":"{{url}}/store/STORE_ID/tag","name":"/stores//tags Create tag in store","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"Tag name\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1666990945452,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_4cf6d863192347d2beb9738fffc06aa6","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491708,"created":1668809491708,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Link an item in a store with a tag from the same store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945477,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_fed862a14dfb4fbca5c16a361e9337b2","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491710,"created":1668809491710,"url":"{{url}}/item/ITEM_ID/tag/TAG_ID","name":"/item//tag/ Unlink a tag from an item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945427,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_2ebdc85b06354c5c8a2ac2a12b9f74d6","parentId":"fld_a2feddc6ef5e44a495722d631fb86a4e","modified":1668809491711,"created":1668809491711,"url":"{{url}}/tag/TAG_ID","name":"/tag/ Delete a tag, which must have no associated items.","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1666990945402,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ea98179fc95645199746896edbe91c33","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809520188,"created":1668809491716,"url":"{{url}}/register","name":"/register Create user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"email\": \"example@gmail.com\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_975ec20d9bec44538591da08c95ccd63"}],"authentication":{},"metaSortKey":-1667328597818,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_86d10d221e114aa2be9542daf31009ec","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491715,"created":1668809491715,"name":"Users","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1667328596182,"_type":"request_group"},{"_id":"req_349d1cc5283848fb92a4911bd38bebc6","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491718,"created":1668809491718,"url":"{{url}}/user/USER_ID","name":"/user/ Get user","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328670383,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_88445b44c1074ac091314fa911a6e0cf","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491720,"created":1668809491720,"url":"{{url}}/user/1","name":"/user/ Delete user","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1667328597768,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82977ddde2e14e7c8ca337842483a5f2","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491722,"created":1668809491722,"url":"{{url}}/login","name":"/login Authenticate user","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"}],"authentication":{},"metaSortKey":-1667328597793,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d0cb7e79bbfd4e97a88ef48ab0ddab14","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491723,"created":1668809491723,"url":"{{url}}/logout","name":"/logout Revoke JWT","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_716a234feee94c3b9e79966a066fc00d"},{"id":"pair_c27fa3320a6943cfbf87c40b7ca6ab73","name":"Authorization","value":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTY2NzMzMjQwNiwianRpIjoiMTU2ZTc5MzUtMmYwYy00NDE2LThmYWItODExYzdmNjY0ZDk4IiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MSwibmJmIjoxNjY3MzMyNDA2LCJleHAiOjE2NjczMzMzMDZ9.zht2vTD73oderxN4uBobsywovxHdDjohbDNGk8_Vuaw","description":""}],"authentication":{},"metaSortKey":-1667328597780.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94b040afec0a43e098320c3b03d5f0d7","parentId":"fld_86d10d221e114aa2be9542daf31009ec","modified":1668809491725,"created":1668809491725,"url":"{{url}}/refresh","name":"/refresh Get non-fresh token","description":"","method":"POST","body":{},"parameters":[],"headers":[{"id":"pair_d002eea6b22e4190909fc8366b5a4704","name":"Authorization","value":"Bearer {{refresh_token}}","description":""}],"authentication":{},"metaSortKey":-1667328634100.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_19db457230041d88ca9420d1b3c0f1f02bbcae93","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666122928025,"created":1666122928025,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_19db457230041d88ca9420d1b3c0f1f02bbcae93","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666122928027,"created":1666122928027,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_c5b803a7c6514ff29573e26487d898d4","parentId":"wrk_6aa19b7d9ecd4f93a3602d257e54a163","modified":1666122928018,"created":1666122928018,"fileName":"Your First REST API","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_6b3e8bb38d0c4154826d63642b863687","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666124761125,"created":1666124761125,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_9b95c15dadb44c03bf60cc7386095847","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666124761128,"created":1666124761128,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_cfb94f75feff4930966c80f350b1e115","parentId":"wrk_acbd4ac11e8a40e5be2e6dad8ec6174a","modified":1666124761155,"created":1666124761131,"fileName":"Flask-Smorest","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_adf22718b4e044e5b54b37c869463582","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666985430514,"created":1666905719000,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_210b7ba8709f44f29c305ed544da17c3","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719004,"created":1666905719004,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_7a427f233a494727845a45ba1325ea85","parentId":"wrk_e6c8aab80c134d35810fd37d43cce51e","modified":1666905719034,"created":1666905719007,"fileName":"Flask-Smorest-Docker","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_892efa21f8454221972d0c77a336872c","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689172,"created":1666987689172,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_aff586a35c4c49aa91c5defb067355bf","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689174,"created":1666987689174,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_4e7424f78749436bacdb44d3a1eba77a","parentId":"wrk_a6cd641e98494bca9a11fe77b66c7e37","modified":1666987689205,"created":1666987689176,"fileName":"Section 6","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_7609e8f1315a4d77af52a6ba50f48205","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745590,"created":1666990745590,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_ce9759718e054191a685cec521ed7afc","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745592,"created":1666990745592,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_0f62897a05a449f9845b4c71eeb892b3","parentId":"wrk_6efa5c8b8fa142a28f436b209fba66fa","modified":1666990745620,"created":1666990745594,"fileName":"Section 7","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_52ad539864c4425884f6394f62627cb7","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1667329454452,"created":1666991842391,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005"},"dataPropertyOrder":{"&":["url"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_bc7c508b3eed4f2485782e9d7177762e","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842395,"created":1666991842395,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_75bd139c1688445faff87b21105f8df1","parentId":"wrk_101d091b922e44c28f66528e9ef2ae37","modified":1666991842435,"created":1666991842397,"fileName":"Section 8","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_c2a4191124a544e9a440f8042644c7b9","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667651225760,"created":1667332566839,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005","access_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'when-expired', 300 %}","refresh_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5yZWZyZXNoX3Rva2Vu::46b', 'when-expired', NaN %}"},"dataPropertyOrder":{"&":["url","access_token","refresh_token"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_8a4500f913d7401fa00168eaf32dcb4c","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566841,"created":1667332566841,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_2d4dfbfd115b4f91b44d09ad3c6d0b5b","parentId":"wrk_5d43c6fa3d9a4ba8817aa2e2767d791a","modified":1667332566888,"created":1667332566842,"fileName":"Section 8 - Chaining","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_e09818240d524d079150f6fc00963c54","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660648,"created":1668007660648,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005","access_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'when-expired', 300 %}","refresh_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5yZWZyZXNoX3Rva2Vu::46b', 'when-expired', NaN %}"},"dataPropertyOrder":{"&":["url","access_token","refresh_token"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_9aa71c77793345cabd413a66ad557bd7","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660652,"created":1668007660652,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_2f42797dc9c6441fb3d3e7b23780dc27","parentId":"wrk_f14186e0f24a4da9ab3ab8d628227a36","modified":1668007660725,"created":1668007660654,"fileName":"Section 11","contents":"","contentType":"yaml","_type":"api_spec"},{"_id":"env_05389734f73848a3922232b339a3d34d","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491667,"created":1668809491667,"name":"Base Environment","data":{"url":"http://127.0.0.1:5005","access_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'when-expired', 300 %}","refresh_token":"{% response 'body', 'req_fe8e836ebd4a44cd9f37ff8738e7ed98', 'b64::JC5yZWZyZXNoX3Rva2Vu::46b', 'when-expired', NaN %}"},"dataPropertyOrder":{"&":["url","access_token","refresh_token"]},"color":null,"isPrivate":false,"metaSortKey":1666122928025,"_type":"environment"},{"_id":"jar_a60cc8f7a1dc4acfa31828ea71765d5e","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491673,"created":1668809491673,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_b684ea4fa67c4d13befbc1e6ee30fdb6","parentId":"wrk_3626a93c829b4cf683cbf49c61bdaaef","modified":1668809491739,"created":1668809491675,"fileName":"Section 12","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: docs/docusaurus.config.js ================================================ // @ts-check // Note: type annotations allow type checking and IDEs autocompletion import { Highlight, themes } from "prism-react-renderer"; /** @type {import('@docusaurus/types').Config} */ const config = { title: "REST APIs with Flask and Python", tagline: "Build and deploy REST APIs using Flask, PostgreSQL, and Docker", url: "https://rest-apis-flask.teclado.com", baseUrl: "/", onBrokenLinks: "throw", onBrokenMarkdownLinks: "warn", favicon: "img/favicon.ico", organizationName: "tecladocode", // Usually your GitHub org/user name. projectName: "rest-apis-flask-python", // Usually your repo name. scripts: [ { src: "https://plau-prox.teclado.workers.dev/get/script.outbound-links.js", defer: true, "data-domain": "rest-apis-flask.teclado.com", "data-api": "https://plau-prox.teclado.workers.dev/send/event", }, ], presets: [ [ "@docusaurus/preset-classic", /** @type {import('@docusaurus/preset-classic').Options} */ ({ docs: { sidebarPath: require.resolve("./sidebars.js"), exclude: ["**/start/**", "**/end/**"], // Please change this to your repo. editUrl: "https://github.com/tecladocode/rest-apis-flask-python/tree/develop/docs/", }, theme: { customCss: require.resolve("./src/css/custom.css"), }, }), ], ], themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({ docs: { sidebar: { hideable: true, }, }, algolia: { // The application ID provided by Algolia appId: "1BEGBIP9SH", // Public API key: it is safe to commit it apiKey: "882167549d623413f9b5314788a0d900", indexName: "docusaurus-2", // Optional: see doc section below // contextualSearch: true, // Optional: Specify domains where the navigation should occur through window.location instead on history.push. Useful when our Algolia config crawls multiple documentation sites and we want to navigate with window.location.href to them. // externalUrlRegex: "external\\.com|domain\\.com", // Optional: Algolia search parameters searchParameters: {}, // Optional: path for search page that enabled by default (`false` to disable it) searchPagePath: "search", }, navbar: { title: "REST APIs with Flask and Python", logo: { alt: "Teclado Logo", src: "img/favicon.ico", }, items: [ { type: "doc", docId: "course_intro/intro", position: "left", label: "Tutorial", }, { href: "/insomnia-files/", position: "left", label: "Insomnia files", }, { href: "https://go.tecla.do/rest-apis-ebook", label: "Get the course", position: "right", }, ], }, announcementBar: { id: "support_us", content: 'Unlock all video lessons and support us by buying the course!', backgroundColor: "#1c2023", textColor: "#fff", isCloseable: false, }, footer: { style: "dark", links: [ { title: "Learn", items: [ { href: "https://go.tecla.do/rest-apis-ebook", label: "Get the course", }, { label: "Tutorial", to: "/docs/course_intro/", }, ], }, { title: "Social", items: [ { label: "Discord", href: "https://go.tecla.do/discord", }, { label: "Twitter", href: "https://twitter.com/jslvtr", }, ], }, { title: "More", items: [ { label: "GitHub", href: "https://github.com/tecladocode/rest-apis-flask-python", }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} Teclado Ltd. Built with Docusaurus.`, }, prism: { theme: themes.okaidia, darkTheme: themes.dracula, additionalLanguages: ["docker"], }, }), }; export default config; ================================================ FILE: docs/package.json ================================================ { "name": "website", "version": "0.0.0", "private": true, "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { "@docusaurus/core": "3.2.0", "@docusaurus/preset-classic": "3.2.0", "@docusaurus/theme-search-algolia": "3.2.0", "@mdx-js/react": "^3.0.1", "clsx": "^2.1.0", "prism-react-renderer": "^2.3.1", "react": "18.2.0", "react-dom": "18.2.0" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } ================================================ FILE: docs/sidebars.js ================================================ /** * Creating a sidebar enables you to: - create an ordered group of docs - render a sidebar for each doc of that group - provide next/previous navigation The sidebars can be generated from the filesystem, or explicitly defined here. Create as many sidebars as you want. */ // @ts-check /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ const sidebars = { // By default, Docusaurus generates a sidebar from the docs folder structure tutorialSidebar: [{ type: "autogenerated", dirName: "." }], // But you can create a sidebar manually /* tutorialSidebar: [ { type: 'category', label: 'Tutorial', items: ['hello'], }, ], */ }; module.exports = sidebars; ================================================ FILE: docs/src/components/HomepageFeatures/index.js ================================================ import React from "react"; import clsx from "clsx"; import styles from "./styles.module.css"; const FeatureList = [ { title: "Everything you need", Svg: require("@site/static/img/product-dev.svg").default, description: ( <> Learn Flask, Docker, PostgreSQL, and more. Build professional-grade REST APIs with Python. ), }, { title: "The latest versions", Svg: require("@site/static/img/cloud-download.svg").default, description: ( <> No more outdated tutorials. Use Python 3.10+ and the latest versions of every Flask extension and library. ), }, { title: "Use best practices", Svg: require("@site/static/img/robot-coding.svg").default, description: ( <> Run your apps in Docker, host your code with Git, write documentation with Swagger, and test your APIs while developing. ), }, ]; function Feature({ Svg, title, description }) { return (

{title}

{description}

); } export default function HomepageFeatures() { return (
{FeatureList.map((props, idx) => ( ))}
); } ================================================ FILE: docs/src/components/HomepageFeatures/styles.module.css ================================================ .features { display: flex; align-items: center; padding: 2rem 0; width: 100%; } .featureSvg { height: 200px; width: 200px; } ================================================ FILE: docs/src/components/LockedVideoEmbed/index.js ================================================ import React from "react"; import Background from "./background.png"; export default function LockedVideoEmbed() { return (

This video is locked. Please{" "} purchase the course {" "} to view it.

); } ================================================ FILE: docs/src/components/VideoEmbed/index.js ================================================ import React from "react"; export default function VideoEmbed({ url }) { return (
); } ================================================ FILE: docs/src/css/custom.css ================================================ /** * Any CSS included here will be global. The classic template * bundles Infima by default. Infima is a CSS framework designed to * work well for content-centric websites. */ /* You can override the default Infima variables here. */ :root { --ifm-color-primary: #2e8555; --ifm-color-primary-dark: #29784c; --ifm-color-primary-darker: #277148; --ifm-color-primary-darkest: #205d3b; --ifm-color-primary-light: #33925d; --ifm-color-primary-lighter: #359962; --ifm-color-primary-lightest: #3cad6e; --ifm-code-font-size: 90%; --ifm-code-padding-horizontal: 0.3rem; --ifm-code-padding-vertical: 0.15rem; --ifm-code-border-radius: 5px; --aa-primary-color-rgb: 0, 0, 0 !important; --aa-input-border-color-rgb: 0, 0, 0 !important; } .aa-DetachedSearchButton { padding-right: 16px !important; border-radius: 8px !important; } /* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme='dark'] { --ifm-color-primary: #25c2a0; --ifm-color-primary-dark: #21af90; --ifm-color-primary-darker: #1fa588; --ifm-color-primary-darkest: #1a8870; --ifm-color-primary-light: #29d5b0; --ifm-color-primary-lighter: #32d8b4; --ifm-color-primary-lightest: #4fddbf; } .docusaurus-highlight-code-line { background-color: rgba(0, 0, 0, 0.1); display: block; margin: 0 calc(-1 * var(--ifm-pre-padding)); padding: 0 var(--ifm-pre-padding); } [data-theme='dark'] .docusaurus-highlight-code-line { background-color: rgba(0, 0, 0, 0.3); } .codeTabContainer { padding: 1rem; background-color: rgba(46, 133, 85, 0.15); border-radius: 8px; } .menu { font-weight: normal; } .menu__caret::before, .menu__link--sublist-caret::after { background: url("/img/folder-closed.svg") no-repeat; transform: none; } .menu__list-item--collapsed .menu__link--sublist::after, .menu__list-item--collapsed .menu__caret::before { transform: none; } .menu__list-item-collapsible .menu__link--sublist[aria-expanded='true']::after, .menu__list-item-collapsible .menu__link--sublist[aria-expanded='true'] + button::before { background: url("/img/folder-open.svg") no-repeat; } .menu__list-item-collapsible .menu__link--sublist[aria-expanded='false']:hover::after, .menu__list-item-collapsible .menu__link--sublist[aria-expanded='false'] + button:hover::before { fill: var(--ifm-color-primary-lightest); } .menu__link { border-top-left-radius: 0; border-bottom-left-radius: 0; } .menu__list-item-collapsible, .theme-doc-sidebar-item-link-level-1 { font-weight: var(--ifm-font-weight-bold); } .theme-doc-sidebar-item-category-level-1 .menu__list-item .menu__link { border-left: 1px solid var(--ifm-color-gray-200); } .theme-doc-sidebar-item-category-level-1 .menu__list-item .menu__link:hover { border-left: 1px solid var(--ifm-color-gray-600); color: var(--ifm-color-gray-900); } .theme-doc-sidebar-item-category-level-1 .menu__list-item .menu__link--active { border-left: 1px solid var(--ifm-color-primary-lightest); font-weight: var(--ifm-font-weight-semibold); } .theme-doc-sidebar-item-category-level-1 .menu__list-item .menu__link--active:hover { color: var(--ifm-color-primary); } .menu__link--active:not(.menu__link--sublist), .menu__list-item-collapsible--active { background: unset; } .menu__link:hover, .menu__caret:hover { background: inherit; } .menu__list-item:not(:first-child) { margin-top: 0; } .menu__link[href]:hover, .menu__caret:hover { color: var(--ifm-color-primary); } .menu__list-item-collapsible:hover { background: inherit; } ================================================ FILE: docs/src/pages/index.js ================================================ import React from "react"; import clsx from "clsx"; import Layout from "@theme/Layout"; import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./index.module.css"; import HomepageFeatures from "@site/src/components/HomepageFeatures"; import VideoEmbed from "@site/src/components/VideoEmbed"; function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); return (

{siteConfig.title}

{siteConfig.tagline}

Read the e-book
); } export default function Home() { const { siteConfig } = useDocusaurusContext(); return (
); } ================================================ FILE: docs/src/pages/index.module.css ================================================ /** * CSS files with the .module.css suffix will be treated as CSS modules * and scoped locally. */ .heroBanner { padding: 4rem 0; text-align: center; position: relative; overflow: hidden; } @media screen and (max-width: 996px) { .heroBanner { padding: 2rem; } } .buttons { display: flex; align-items: center; justify-content: center; } ================================================ FILE: docs/src/pages/insomnia-files.md ================================================ # Insomnia Files for this course In this course, we use [Insomnia](https://insomnia.rest/) to test our REST API as we develop it. It's a great tool, and relatively straightforward to use! To make it easier for you, we've prepared a few files that you can import directly into your Insomnia app. Each section has its own file, and there's also a file which contains [all sections](#all-section-file-project). :::tip Get the files You can download the entire GitHub repository as a .zip file ([see how here](https://github.com/tecladocode/rest-apis-flask-python#getting-started)). Then, extract it and you'll be able to find the JSON files for Insomnia. ::: ## Per-section files (collections) Every section where we change the API endpoints, we've prepared an Insomnia file you can import. To import the file, just go to your Insomnia project, click on the "Create" button, and select "Import from file". ![Screenshot showing dropdown appears when clicking 'create' button, select import from file there](./assets/in-import-file.png) Then, find the appropriate file and you should see the request collection being created. Files are in each section folder where API changes were introduced, and they are named `Insomnia_sectionX.json`. ![Screenshot showing instructor finding the JSON file to import](./assets/in-import-file-select.png) The Insomnia project should now show your new collection: ![Screenshot showing the import succeeded and the collection appears](./assets/in-import-success.png) Clicking on it should let you access the requests, which may be inside folders. ![Screenshot showing the collection open, with the Stores folder expanded and the 'Create store' endpoint selected](./assets/in-section-import-s03.png) ## All-section file (project) To import all sections at the same time, you can use the all-section file. It's in the `docs/docs/` folder of the repository, and it's called [`Insomnia_all_sections.json`](https://github.com/tecladocode/rest-apis-flask-python/blob/develop/docs/docs/Insomnia_all_sections.json). To import it, follow the same process as before but using this file. It will create multiple request collections. First, find the `Insomnia_all_sections.json` file when importing: ![Screenshot showing the instructor finding the all-sections file when importing using Insomnia](./assets/in-import-all-sections-file-select.png) This should import the request collections for all sections: ![Screenshot showing the import succeeding, and request collections appear in Insomnia for every section where API changes were introduced](./assets/in-import-all-sections-success.png) That's it! With this, you can either import sections individually, or all sections together to follow the entire course. Now let's start with the [course content](/docs/course_intro/)! ================================================ FILE: project/01-first-rest-api/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [ { "name": "My Store", "items": [ { "name": "Chair", "price": 15.99 } ] } ] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: project/02-first-rest-api-docker/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app RUN pip install flask COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: project/02-first-rest-api-docker/app.py ================================================ from flask import Flask, request app = Flask(__name__) stores = [ { "name": "My Store", "items": [ { "name": "Chair", "price": 15.99 } ] } ] @app.get("/store") def get_stores(): return {"stores": stores} @app.post("/store") def create_store(): request_data = request.get_json() new_store = {"name": request_data["name"], "items": []} stores.append(new_store) return new_store, 201 @app.post("/store//item") def create_item(name): request_data = request.get_json() for store in stores: if store["name"] == name: new_item = {"name": request_data["name"], "price": request_data["price"]} store["items"].append(new_item) return new_item, 201 return {"message": "Store not found"}, 404 @app.get("/store/") def get_store(name): for store in stores: if store["name"] == name: return store return {"message": "Store not found"}, 404 @app.get("/store//item") def get_item_in_store(name): for store in stores: if store["name"] == name: return {"items": store["items"]} return {"message": "Store not found"}, 404 ================================================ FILE: project/03-items-stores-smorest/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/03-items-stores-smorest/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: project/03-items-stores-smorest/app.py ================================================ from flask import Flask from flask_smorest import Api from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint app = Flask(__name__) app.config["PROPAGATE_EXCEPTIONS"] = True app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) ================================================ FILE: project/03-items-stores-smorest/db.py ================================================ """ db.py --- Later on, this file will be replaced by SQLAlchemy. For now, it mimics a database. Our data storage is: - stores have a unique ID and a name - items have a unique ID, a name, a price, and a store ID. """ stores = {} items = {} ================================================ FILE: project/03-items-stores-smorest/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow ================================================ FILE: project/03-items-stores-smorest/resources/__init__.py ================================================ ================================================ FILE: project/03-items-stores-smorest/resources/item.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from schemas import ItemSchema, ItemUpdateSchema from db import items blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): try: return items[item_id] except KeyError: abort(404, message="Item not found.") def delete(self, item_id): try: del items[item_id] return {"message": "Item deleted."} except KeyError: abort(404, message="Item not found.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): try: item = items[item_id] # https://blog.teclado.com/python-dictionary-merge-update-operators/ item |= item_data return item except KeyError: abort(404, message="Item not found.") @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return items.values() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): for item in items.values(): if ( item_data["name"] == item["name"] and item_data["store_id"] == item["store_id"] ): abort(400, message=f"Item already exists.") item_id = uuid.uuid4().hex item = {**item_data, "id": item_id} items[item_id] = item return item ================================================ FILE: project/03-items-stores-smorest/resources/store.py ================================================ import uuid from flask.views import MethodView from flask_smorest import Blueprint, abort from db import stores from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(cls, store_id): try: # You presumably would want to include the store's items here too # More on that when we look at databases return stores[store_id] except KeyError: abort(404, message="Store not found.") def delete(cls, store_id): try: del stores[store_id] return {"message": "Store deleted."} except KeyError: abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(cls): return stores.values() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(cls, store_data): for store in stores.values(): if store_data["name"] == store["name"]: abort(400, message=f"Store already exists.") store_id = uuid.uuid4().hex store = {**store_data, "id": store_id} stores[store_id] = store return store ================================================ FILE: project/03-items-stores-smorest/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) store_id = fields.Int(required=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(Schema): id = fields.Str(dump_only=True) name = fields.Str(required=True) ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/app.py ================================================ from flask import Flask from flask_smorest import Api from db import db import models from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) return app ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/models/__init__.py ================================================ from models.item import ItemModel from models.store import StoreModel ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/requirements.txt ================================================ flask flask-smorest python-dotenv marshmallow sqlalchemy flask-sqlalchemy ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/resources/__init__.py ================================================ ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: project/04-items-stores-smorest-sqlalchemy/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) ================================================ FILE: project/05-add-many-to-many/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/05-add-many-to-many/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: project/05-add-many-to-many/app.py ================================================ from flask import Flask from flask_smorest import Api import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) with app.app_context(): db.create_all() api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: project/05-add-many-to-many/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: project/05-add-many-to-many/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: project/05-add-many-to-many/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: project/05-add-many-to-many/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: project/05-add-many-to-many/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: project/05-add-many-to-many/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: project/05-add-many-to-many/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: project/05-add-many-to-many/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv gunicorn ================================================ FILE: project/05-add-many-to-many/resources/__init__.py ================================================ ================================================ FILE: project/05-add-many-to-many/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: project/05-add-many-to-many/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: project/05-add-many-to-many/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: project/05-add-many-to-many/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: project/05-add-many-to-many/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: project/05-add-many-to-many/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: project/05-add-many-to-many/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: project/05-add-many-to-many/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) ================================================ FILE: project/06-add-db-migrations/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/06-add-db-migrations/.python-version ================================================ 3.10.4 ================================================ FILE: project/06-add-db-migrations/Dockerfile ================================================ FROM python:3.10 EXPOSE 5000 WORKDIR /app COPY ./requirements.txt requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt COPY . . CMD ["flask", "run", "--host", "0.0.0.0"] ================================================ FILE: project/06-add-db-migrations/app.py ================================================ from flask import Flask from flask_smorest import Api from flask_migrate import Migrate import models from db import db from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint def create_app(db_url=None): app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config[ "OPENAPI_SWAGGER_UI_URL" ] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = db_url or "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) migrate = Migrate(app, db) api = Api(app) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) return app ================================================ FILE: project/06-add-db-migrations/conftest.py ================================================ import pytest from app import create_app @pytest.fixture() def app(): app = create_app("sqlite://") app.config.update( { "TESTING": True, } ) yield app @pytest.fixture() def client(app): return app.test_client() ================================================ FILE: project/06-add-db-migrations/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: project/06-add-db-migrations/migrations/README ================================================ Single-database configuration for Flask. ================================================ FILE: project/06-add-db-migrations/migrations/alembic.ini ================================================ # A generic, single database configuration. [alembic] # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # Logging configuration [loggers] keys = root,sqlalchemy,alembic,flask_migrate [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [logger_flask_migrate] level = INFO handlers = qualname = flask_migrate [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: project/06-add-db-migrations/migrations/env.py ================================================ from __future__ import with_statement import logging from logging.config import fileConfig from flask import current_app from alembic import context # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata config.set_main_option( 'sqlalchemy.url', str(current_app.extensions['migrate'].db.get_engine().url).replace( '%', '%%')) target_metadata = current_app.extensions['migrate'].db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ # this callback is used to prevent an auto-migration from being generated # when there are no changes to the schema # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html def process_revision_directives(context, revision, directives): if getattr(config.cmd_opts, 'autogenerate', False): script = directives[0] if script.upgrade_ops.is_empty(): directives[:] = [] logger.info('No changes in schema detected.') connectable = current_app.extensions['migrate'].db.get_engine() with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, **current_app.extensions['migrate'].configure_args ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: project/06-add-db-migrations/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: project/06-add-db-migrations/migrations/versions/5acd69659946_.py ================================================ """empty message Revision ID: 5acd69659946 Revises: Create Date: 2022-06-17 14:13:44.923682 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '5acd69659946' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('stores', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('price', sa.Float(precision=2), nullable=False), sa.Column('store_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=80), nullable=False), sa.Column('store_id', sa.String(), nullable=False), sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) op.create_table('items_tags', sa.Column('id', sa.Integer(), nullable=False), sa.Column('item_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['item_id'], ['items.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('items_tags') op.drop_table('tags') op.drop_table('items') op.drop_table('stores') # ### end Alembic commands ### ================================================ FILE: project/06-add-db-migrations/migrations/versions/a40bdfbd7a9d_.py ================================================ """empty message Revision ID: a40bdfbd7a9d Revises: 5acd69659946 Create Date: 2022-06-17 14:19:34.934726 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'a40bdfbd7a9d' down_revision = '5acd69659946' branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.add_column('items', sa.Column('description', sa.String(), nullable=True)) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_column('items', 'description') # ### end Alembic commands ### ================================================ FILE: project/06-add-db-migrations/models/__init__.py ================================================ from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: project/06-add-db-migrations/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) description = db.Column(db.String) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") ================================================ FILE: project/06-add-db-migrations/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: project/06-add-db-migrations/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) tags = db.relationship("TagModel", back_populates="store", lazy="dynamic") items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") ================================================ FILE: project/06-add-db-migrations/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) store = db.relationship("StoreModel", back_populates="tags") items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") ================================================ FILE: project/06-add-db-migrations/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy Flask-Migrate passlib marshmallow python-dotenv gunicorn ================================================ FILE: project/06-add-db-migrations/resources/__init__.py ================================================ ================================================ FILE: project/06-add-db-migrations/resources/__tests__/conftest.py ================================================ import pytest @pytest.fixture() def created_store_id(client): response = client.post( "/store", json={"name": "Test Store"}, ) return response.json["id"] @pytest.fixture() def created_item_id(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) return response.json["id"] @pytest.fixture() def created_tag_id(client, created_store_id): response = client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) return response.json["id"] ================================================ FILE: project/06-add-db-migrations/resources/__tests__/test_item.py ================================================ def test_create_item_in_store(client, created_store_id): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_create_item_with_store_id_not_found(client): # Note that this will fail if foreign key constraints are enabled. response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) assert response.status_code == 201 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] is None def test_create_item_with_unknown_data(client): response = client.post( "/item", json={ "name": "Test Item", "price": 10.5, "store_id": 1, "unknown_field": "unknown", }, ) assert response.status_code == 422 assert response.json["errors"]["json"]["unknown_field"] == ["Unknown field."] def test_delete_item(client, created_item_id): response = client.delete( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["message"] == "Item deleted." def test_update_item(client, created_item_id): response = client.put( f"/item/{created_item_id}", json={"name": "Test Item (updated)", "price": 12.5}, ) assert response.status_code == 200 assert response.json["name"] == "Test Item (updated)" assert response.json["price"] == 12.5 def test_get_all_items(client): response = client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.post( "/item", json={"name": "Test Item 2", "price": 10.5, "store_id": 1}, ) response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 2 assert response.json[0]["name"] == "Test Item" assert response.json[0]["price"] == 10.5 assert response.json[1]["name"] == "Test Item 2" def test_get_all_items_empty(client): response = client.get( "/item", ) assert response.status_code == 200 assert len(response.json) == 0 def test_get_item_details(client, created_item_id, created_store_id): response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["store"] == {"id": created_store_id, "name": "Test Store"} def test_get_item_details_with_tag(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/item/{created_item_id}", ) assert response.status_code == 200 assert response.json["name"] == "Test Item" assert response.json["price"] == 10.5 assert response.json["tags"] == [{"id": created_tag_id, "name": "Test Tag"}] def test_get_item_detail_not_found(client): response = client.get( "/item/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: project/06-add-db-migrations/resources/__tests__/test_store.py ================================================ def test_get_store(client, created_store_id): response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Store", "items": [], "tags": [], } def test_get_store_not_found(client): response = client.get( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_with_item(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": created_store_id}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_get_store_with_tag(client, created_store_id): client.post( f"/store/{created_store_id}/tag", json={"name": "Test Tag"}, ) response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["tags"] == [{"id": 1, "name": "Test Tag"}] def test_create_store(client): response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 201 assert response.json["name"] == "Test Store" def test_create_store_with_items(client, created_store_id): client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) # Get the store with id 1 and check the items contains the newly created item response = client.get( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_delete_store(client, created_store_id): response = client.delete( f"/store/{created_store_id}", ) assert response.status_code == 200 assert response.json == {"message": "Store deleted"} def test_delete_store_doesnt_exist(client): response = client.delete( "/store/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_store_list_empty(client): response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [] def test_get_store_list_single(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [{"id": 1, "name": "Test Store", "items": [], "tags": []}] def test_get_store_list_multiple(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/store", json={"name": "Test Store 2"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ {"id": 1, "name": "Test Store", "items": [], "tags": []}, {"id": 2, "name": "Test Store 2", "items": [], "tags": []}, ] def test_get_store_list_with_items(client): client.post( "/store", json={"name": "Test Store"}, ) client.post( "/item", json={"name": "Test Item", "price": 10.5, "store_id": 1}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [ { "id": 1, "name": "Test Item", "price": 10.5, } ], "tags": [], } ] def test_get_store_list_with_tags(client): resp = client.post( "/store", json={"name": "Test Store"}, ) client.post( f"/store/{resp.json['id']}/tag", json={"name": "Test Tag"}, ) response = client.get( "/store", ) assert response.status_code == 200 assert response.json == [ { "id": 1, "name": "Test Store", "items": [], "tags": [{"id": 1, "name": "Test Tag"}], } ] def test_create_store_duplicate_name(client): client.post( "/store", json={"name": "Test Store"}, ) response = client.post( "/store", json={"name": "Test Store"}, ) assert response.status_code == 400 assert response.json["message"] == "A store with that name already exists." ================================================ FILE: project/06-add-db-migrations/resources/__tests__/test_tag.py ================================================ import pytest import logging LOGGER = logging.getLogger(__name__) @pytest.fixture() def created_tag_with_item_id(client, created_item_id, created_tag_id): client.post(f"/item/{created_item_id}/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) return response.json["id"] def test_get_tag(client, created_tag_id): response = client.get( f"/tag/{created_tag_id}", ) assert response.status_code == 200 assert response.json == { "id": 1, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } def test_get_tag_not_found(client): response = client.get( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_items_linked_with_tag(client, created_tag_with_item_id): response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [ { "id": 1, "name": "Test Item", "price": 10.5, } ] def test_unlink_tag_from_item(client, created_item_id, created_tag_with_item_id): client.delete(f"/item/{created_item_id}/tag/{created_tag_with_item_id}") response = client.get( f"/tag/{created_tag_with_item_id}", ) assert response.status_code == 200 assert response.json["items"] == [] def test_delete_tag_without_items(client, created_tag_id): delete_response = client.delete(f"/tag/{created_tag_id}") response = client.get( f"/tag/{created_tag_id}", ) assert delete_response.status_code == 202 assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_delete_tag_still_has_items(client, created_tag_with_item_id): response = client.delete(f"/tag/{created_tag_with_item_id}") assert response.status_code == 400 assert ( response.json["message"] == "Could not delete tag. Make sure tag is not associated with any items, then try again." ) def test_delete_tag_not_found(client): response = client.delete( "/tag/1", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} def test_get_all_tags_in_store(client, created_store_id, created_tag_id): response = client.get( f"/store/{created_store_id}/tag", ) assert response.status_code == 200 assert response.json == [ { "id": created_tag_id, "name": "Test Tag", "items": [], "store": {"id": 1, "name": "Test Store"}, } ] def test_get_all_tags_in_store_not_found(client): response = client.get( "/store/1/tag", ) assert response.status_code == 404 assert response.json == {"code": 404, "status": "Not Found"} ================================================ FILE: project/06-add-db-migrations/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @blp.response(200, ItemSchema) def get(self, item_id): item = ItemModel.query.get_or_404(item_id) return item def delete(self, item_id): item = ItemModel.query.get_or_404(item_id) db.session.delete(item) db.session.commit() return {"message": "Item deleted."} @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, item_id): item = ItemModel.query.get(item_id) if item: item.price = item_data["price"] item.name = item_data["name"] else: item = ItemModel(id=item_id, **item_data) db.session.add(item) db.session.commit() return item @blp.route("/item") class ItemList(MethodView): @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.query.all() @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data): item = ItemModel(**item_data) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item ================================================ FILE: project/06-add-db-migrations/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError, IntegrityError from db import db from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store def delete(self, store_id): store = StoreModel.query.get_or_404(store_id) db.session.delete(store) db.session.commit() return {"message": "Store deleted"}, 200 @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(self): return StoreModel.query.all() @blp.arguments(StoreSchema) @blp.response(201, StoreSchema) def post(self, store_data): store = StoreModel(**store_data) try: db.session.add(store) db.session.commit() except IntegrityError: abort( 400, message="A store with that name already exists.", ) except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store ================================================ FILE: project/06-add-db-migrations/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from db import db from models import TagModel, StoreModel, ItemModel from schemas import TagSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/store//tag") class TagsInStore(MethodView): @blp.response(200, TagSchema(many=True)) def get(self, store_id): store = StoreModel.query.get_or_404(store_id) return store.tags.all() # lazy="dynamic" means 'tags' is a query @blp.arguments(TagSchema) @blp.response(201, TagSchema) def post(self, tag_data, store_id): if TagModel.query.filter(TagModel.store_id == store_id, TagModel.name == tag_data["name"]).first(): abort(400, message="A tag with that name already exists in that store.") tag = TagModel(**tag_data, store_id=store_id) try: db.session.add(tag) db.session.commit() except SQLAlchemyError as e: abort( 500, message=str(e), ) return tag @blp.route("/item//tag/") class LinkTagsToItem(MethodView): @blp.response(201, TagSchema) def post(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.append(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.response(200, TagAndItemSchema) def delete(self, item_id, tag_id): item = ItemModel.query.get_or_404(item_id) tag = TagModel.query.get_or_404(tag_id) item.tags.remove(tag) try: db.session.add(item) db.session.commit() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return {"message": "Item removed from tag", "item": item, "tag": tag} @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, tag_id): tag = TagModel.query.get_or_404(tag_id) return tag @blp.response( 202, description="Deletes a tag if no item is tagged with it.", example={"message": "Tag deleted."}, ) @blp.alt_response(404, description="Tag not found.") @blp.alt_response( 400, description="Returned if the tag is assigned to one or more items. In this case, the tag is not deleted.", ) def delete(self, tag_id): tag = TagModel.query.get_or_404(tag_id) if not tag.items: db.session.delete(tag) db.session.commit() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) ================================================ FILE: project/06-add-db-migrations/schemas.py ================================================ from marshmallow import Schema, fields class PlainItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True) price = fields.Float(required=True) class PlainStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class PlainTagSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str() class ItemSchema(PlainItemSchema): store_id = fields.Int(required=True, load_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class ItemUpdateSchema(Schema): name = fields.Str() price = fields.Float() class StoreSchema(PlainStoreSchema): items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) tags = fields.List(fields.Nested(PlainTagSchema()), dump_only=True) class TagSchema(PlainTagSchema): store_id = fields.Int(load_only=True) items = fields.List(fields.Nested(PlainItemSchema()), dump_only=True) store = fields.Nested(PlainStoreSchema(), dump_only=True) class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) ================================================ FILE: project/using-flask-restful/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/using-flask-restful/Flask-JWT-Extended.postman_collection.json ================================================ { "info": { "_postman_id": "74a1833f-bc4e-4e85-a525-72d268ab9999", "name": "Flask-JWT-Extended", "description": "This collection contains requests associated witht the Flask-JWT-Extended section of the REST API course.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "users", "description": "", "item": [ { "name": "register a new user", "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/register", "host": [ "{{server_address}}" ], "path": [ "register" ] } }, "response": [] }, { "name": "get user by id", "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/user/1", "host": [ "{{server_address}}" ], "path": [ "user", "1" ] } }, "response": [] }, { "name": "delete user by id", "request": { "method": "DELETE", "header": [], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/user/2", "host": [ "{{server_address}}" ], "path": [ "user", "2" ] } }, "response": [] }, { "name": "login", "event": [ { "listen": "test", "script": { "id": "8c0c0ed6-c206-4c88-9349-429e024e312b", "type": "text/javascript", "exec": [ "var jsonData = pm.response.json();", "pm.test(\"access_token not empty\", function () {", " pm.expect(jsonData.access_token).not.eql(undefined);", "});", "", "pm.test(\"refresh token not empty\", function () {", " pm.expect(jsonData.refresh_token).not.eql(undefined);", "});", "// set access token as environement variable", "if (jsonData.access_token !== undefined) {", " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", "} else {", " postman.setEnvironmentVariable(\"access_token\", null);", "}", "// set refresh token as environement variable", "if (jsonData.refresh_token !== undefined) {", " postman.setEnvironmentVariable(\"refresh_token\", jsonData.refresh_token);", "} else {", " postman.setEnvironmentVariable(\"refresh_token\", null);", "}" ] } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"username\" : \"jose\",\n \"password\" : \"1234\"\n}" }, "url": { "raw": "{{server_address}}/login", "host": [ "{{server_address}}" ], "path": [ "login" ] } }, "response": [] }, { "name": "logout", "event": [ { "listen": "test", "script": { "id": "dc763e9b-e6c7-4ff3-9766-637976a5c64b", "type": "text/javascript", "exec": [ "" ] } } ], "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{server_address}}/logout", "host": [ "{{server_address}}" ], "path": [ "logout" ] } }, "response": [] }, { "name": "refresh token", "event": [ { "listen": "test", "script": { "id": "ad818ea6-8f79-436e-b756-ad878666ae9e", "type": "text/javascript", "exec": [ "var jsonData = pm.response.json();", "pm.test(\"access_token not empty\", function () {", " pm.expect(jsonData.access_token).not.eql(undefined);", "});", "// set access token as environement variable", "if (jsonData.access_token !== undefined) {", " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", "} else {", " postman.setEnvironmentVariable(\"access_token\", null);", "}" ] } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{refresh_token}}" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{local_flask}}/refresh", "host": [ "{{local_flask}}" ], "path": [ "refresh" ] } }, "response": [] } ] }, { "name": "items", "description": "", "item": [ { "name": "get item/name", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": {}, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "post item/name", "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "put item/name", "request": { "method": "PUT", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "delete item by name", "request": { "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "get all items", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": { "mode": "raw", "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], "path": [ "items" ] } }, "response": [] }, { "name": "get all items without JWT", "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], "path": [ "items" ] } }, "response": [] } ] }, { "name": "stores", "description": "", "item": [ { "name": "create a new store", "request": { "method": "POST", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "get store by name", "request": { "method": "GET", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "delete a new store by name", "request": { "method": "DELETE", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "get all stores", "request": { "method": "GET", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store", "host": [ "{{server_address}}" ], "path": [ "stores" ] } }, "response": [] } ] } ] } ================================================ FILE: project/using-flask-restful/Stores_REST_API_2022-01-14.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-01-14T11:50:51.742Z","__export_source":"insomnia.desktop.app:v2021.7.2","resources":[{"_id":"req_efcadee1c4fc48f099644e23398a5d29","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159057139,"created":1642157007062,"url":"{{url}}/register","name":"/register","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_64c3752c7f694f0aa830bacba3b35aea"},{"name":"Authorization","value":"JWT","id":"pair_d143b36c4aa74f9681dc1590970da3b7"}],"authentication":{},"metaSortKey":-1642157660252,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_fd1f956aae16470fafdc3d611d34a80a","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157670592,"created":1642157670592,"name":"Authentication","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157670592,"_type":"request_group"},{"_id":"wrk_c441bf446d174d1bb2f01c7ad66c695b","parentId":null,"modified":1642157007080,"created":1642149963161,"name":"Stores REST API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_16415c75944342dab73119513e7bd20b","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159087108,"created":1642157007061,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_a8caec1064eb43b7ac5c8c9294be13a3"}],"authentication":{},"metaSortKey":-1642157660202,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b08c51961bea4413a31fba1af93b3759","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642159093249,"created":1642157007054,"url":"{{url}}/item/my_item","name":"/item/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": 3\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"},{"id":"pair_4926e48dcb594eaa9c79a78b801b708f","name":"Authorization","value":"Bearer {% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'always', 60 %}","description":""}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_5bf669c32c3145a3a80dee2d6523f9ac","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157649712,"created":1642157649712,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157649712,"_type":"request_group"},{"_id":"req_e553e5091f714becb81e1b27bfc8f34b","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642160992156,"created":1642157007053,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"Bearer {{access_token}}","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f64b6c8fc8642aa9c267c8d49c72435","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157976127,"created":1642157007052,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_62498017fcb34ba0a3a19b4e0f2d4499","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69d7ed86b4dc4b72a72778f97a77e05c","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157658823,"created":1642157007048,"url":"{{url}}/item","name":"/item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007178.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_054be716de114cc49d6e49d04a5a901b","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642159101503,"created":1642157684047,"url":"{{url}}/tag/my_tag","name":"/tag/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"item_id\": {% response 'body', 'req_e553e5091f714becb81e1b27bfc8f34b', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_adac84f9834d4e948ceb02807787c935","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157684028,"created":1642157684028,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157641282.5,"_type":"request_group"},{"_id":"req_a5b2631adf9e4ef894a8a1b9d2c77aa8","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158069229,"created":1642157684046,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"JWT ","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5dbef7a9b30d4ee68c148c4af447c241","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158073367,"created":1642157684041,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7e8e3838e1cb41b485e091bf667b0764","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157645538,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320140.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157632853,"created":1642157632853,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157632853,"_type":"request_group"},{"_id":"req_f99ce0192797434f99657221acc45fe3","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157643850,"created":1642157007059,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320090.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82705a91a36849b09f1347d135816761","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157642682,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320040.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94efb5c0488d43d8be95fd82b33afb97","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157639015,"created":1642157007060,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157319990.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9bc4db6f4d02466aba86edef29722854","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157781373,"created":1642157007070,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_8c8f8b7b9ddd4c3ca7fb6df5418b7f2e"}],"authentication":{},"metaSortKey":-1642157007070,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_05fce0fdc405492d8a7f87842e6d4e13","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157017423,"created":1642157007072,"name":"User create store and item","description":"Check user can register.\nCheck user can create store.\nCheck user can create item in store.","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157007128,"_type":"request_group"},{"_id":"req_2ce4ecd840094ac1a164d7a0bfbb6d83","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157887543,"created":1642157007069,"url":"{{url}}/store/test_store","name":"/store/test_store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007069,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8c68f13e77a74937b62cde1ae24bed61","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157923539,"created":1642157007068,"url":"{{url}}/item/test_item","name":"/item/test_item in test_store","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": {% response 'body', 'req_2ce4ecd840094ac1a164d7a0bfbb6d83', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_d0c5451b8b044511b76436c627ffc4bb"},{"id":"pair_eb8ca7f686334ae5a48ad48412436ad9","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007068,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_297f6099ee274c1f8ccceb3bc29ad582","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007065,"created":1642157007065,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007065,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84a807bef7cd4a66bc81f5401f0639cd","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157931016,"created":1642157007064,"url":"{{url}}/item/test_item","name":"/item/my_item copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_daca8133eb94474ca84748a0e4c8bcaf","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007064,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_10076b1332f2458e897d7b5200c7e5de","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007063,"created":1642157007063,"url":"{{url}}/store/test_store","name":"/store/ copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007063,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642160933906,"created":1642149963165,"name":"Base Environment","data":{"url":"http://127.0.0.1:5000","access_token":"{% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'never', 60 %}"},"dataPropertyOrder":{"&":["url","access_token"]},"color":null,"isPrivate":false,"metaSortKey":1642149963165,"_type":"environment"},{"_id":"jar_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963166,"created":1642149963166,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_f25b8aff2219447aa56189a385b1663c","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963162,"created":1642149963162,"fileName":"Stores REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: project/using-flask-restful/app.py ================================================ from flask import Flask, jsonify from flask_restful import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import UserRegister, UserLogin, User, TokenRefresh, UserLogout from resources.item import Item, ItemList from resources.store import Store, StoreList from resources.tag import Tag app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) """ JWT related configuration. The following functions includes: 1) add claims to each jwt 2) customize the token expired error message """ app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) """ `claims` are data we choose to attach to each jwt payload and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` one possible use case for claims are access level control, which is shown below """ @jwt.additional_claims_loader def add_claims_to_jwt(identity): # TODO: Read from a config file instead of hard-coding if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return jsonify({"message": "The token has expired.", "error": "token_expired"}), 401 @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token is not fresh.", "error": "fresh_token_required"} ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.add_resource(UserRegister, "/register") api.add_resource(UserLogin, "/login") api.add_resource(UserLogout, "/logout") api.add_resource(User, "/user/") api.add_resource(TokenRefresh, "/refresh") api.add_resource(Store, "/store/") api.add_resource(StoreList, "/store") api.add_resource(Item, "/item/") api.add_resource(ItemList, "/item") api.add_resource(Tag, "/tag/") ================================================ FILE: project/using-flask-restful/blocklist.py ================================================ """ blacklist.py This file just contains the blacklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blacklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: project/using-flask-restful/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: project/using-flask-restful/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: project/using-flask-restful/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") def json(self): return { "id": self.id, "name": self.name, "price": self.price, "store_id": self.store_id, "tags": [tag.json() for tag in self.tags], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restful/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: project/using-flask-restful/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") def json(self): return { "id": self.id, "name": self.name, "items": [item.json() for item in self.items.all()], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restful/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") def json(self): return { "id": self.id, "name": self.name, "items": [item.name for item in self.items], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restful/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) def json(self): return { 'id': self.id, 'username': self.username } @classmethod def find_by_username(cls, username): return cls.query.filter_by(username=username).first() @classmethod def find_by_id(cls, _id): return cls.query.filter_by(id=_id).first() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restful/requirements.txt ================================================ Flask-JWT-Extended Flask-RESTful Flask-SQLAlchemy passlib python-dotenv ================================================ FILE: project/using-flask-restful/resources/__init__.py ================================================ ================================================ FILE: project/using-flask-restful/resources/item.py ================================================ from flask_restful import Resource, reqparse from flask_jwt_extended import get_jwt_identity, jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from models import ItemModel class Item(Resource): parser = reqparse.RequestParser() parser.add_argument( "price", type=float, required=True, help="This field cannot be left blank!" ) parser.add_argument( "store_id", type=int, required=True, help="Every item needs a store_id." ) @jwt_required() def get(self, name): item = ItemModel.find_by_name(name) if item: return item.json() return {"message": "Item not found"}, 404 @jwt_required(fresh=True) def post(self, name): if ItemModel.find_by_name(name): return { "message": "An item with name '{}' already exists.".format(name) }, 400 data = self.parser.parse_args() item = ItemModel(name=name, **data) try: item.save_to_db() except SQLAlchemyError: return {"message": "An error occurred while inserting the item."}, 500 return item.json(), 201 @jwt_required() def delete(self, name): jwt = get_jwt() if not jwt["is_admin"]: return {"message": "Admin privilege required."}, 401 item = ItemModel.find_by_name(name) if item: item.delete_from_db() return {"message": "Item deleted."} return {"message": "Item not found."}, 404 def put(self, name): data = self.parser.parse_args() item = ItemModel.find_by_name(name) if item: item.price = data["price"] else: item = ItemModel(name, **data) item.save_to_db() return item.json() class ItemList(Resource): @jwt_required(optional=True) def get(self): user_id = get_jwt_identity() items = [item.json() for item in ItemModel.find_all()] if user_id: return {"items": items}, 200 return { "items": [item["name"] for item in items], "message": "More data available if you log in.", }, 200 ================================================ FILE: project/using-flask-restful/resources/store.py ================================================ from flask_restful import Resource from sqlalchemy.exc import SQLAlchemyError from models import StoreModel class Store(Resource): @classmethod def get(cls, name): store = StoreModel.find_by_name(name) if store: return store.json() return {"message": "Store not found"}, 404 @classmethod def post(cls, name): if StoreModel.find_by_name(name): return { "message": "A store with name '{}' already exists.".format(name) }, 400 store = StoreModel(name=name) try: store.save_to_db() except SQLAlchemyError: return {"message": "An error occurred creating the store."}, 500 return store.json(), 201 @classmethod def delete(cls, name): store = StoreModel.find_by_name(name) if store: store.delete_from_db() return {"message": "Store deleted"}, 200 return {"message": "Store not found"}, 404 class StoreList(Resource): @classmethod def get(cls): return {"stores": [store.json() for store in StoreModel.find_all()]} ================================================ FILE: project/using-flask-restful/resources/tag.py ================================================ from flask_restful import Resource, reqparse from werkzeug.exceptions import BadRequest from sqlalchemy.exc import SQLAlchemyError from models import TagModel from models import ItemModel class Tag(Resource): parser = reqparse.RequestParser() parser.add_argument( "item_id", type=int, required=True, help="To create or add a tag to an item, please provide the item_id.", ) def get(self, name): tag = TagModel.find_by_name(name) if tag: return tag.json() return {"message": "Tag not found"}, 404 def post(self, name): tag = TagModel.find_by_name(name) if not tag: tag = TagModel(name=name) # Add the item to the tag data = self.parser.parse_args() item = ItemModel.query.get(data["item_id"]) if not item: return {"message": "An item with this item_id doesn't exist."}, 400 tag.items.append(item) try: tag.save_to_db() except SQLAlchemyError: return {"message": "An error occurred while inserting the tag."}, 500 return tag.json(), 201 def delete(self, name): tag = TagModel.find_by_name(name) try: data = self.parser.parse_args() if "item_id" in data: item = ItemModel.query.get(data["item_id"]) tag.items.remove(item) return { "message": "Item removed from tag", "item": item.json(), "tag": tag.json(), } except BadRequest: # Assume no item_id was passed. Instead delete entire tag. # First check tag has no items if not tag.items: tag.delete_from_db() return {"message": "Tag deleted."} return { "message": "Could not delete tag. Make sure tag is not associated with any items, then try again." } return {"message": "Tag not found."}, 404 ================================================ FILE: project/using-flask-restful/resources/user.py ================================================ from flask_restful import Resource, reqparse from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from models import UserModel from blocklist import BLOCKLIST _user_parser = reqparse.RequestParser() _user_parser.add_argument( "username", type=str, required=True, help="This field cannot be blank." ) _user_parser.add_argument( "password", type=str, required=True, help="This field cannot be blank." ) class UserRegister(Resource): def post(self): data = _user_parser.parse_args() if UserModel.find_by_username(data["username"]): return {"message": "A user with that username already exists"}, 400 user = UserModel( username=data["username"], password=pbkdf2_sha256.hash(data["password"]) ) user.save_to_db() return {"message": "User created successfully."}, 201 class UserLogin(Resource): def post(self): data = _user_parser.parse_args() user = UserModel.find_by_username(data["username"]) if user and pbkdf2_sha256.verify(data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 return {"message": "Invalid Credentials!"}, 401 class UserLogout(Resource): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 class User(Resource): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @classmethod def get(cls, user_id): user = UserModel.find_by_id(user_id) if not user: return {"message": "User Not Found"}, 404 return user.json(), 200 def delete(self, user_id): user = UserModel.find_by_id(user_id) if not user: return {"message": "User Not Found"}, 404 user.delete_from_db() return {"message": "User deleted."}, 200 class TokenRefresh(Resource): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token}, 200 ================================================ FILE: project/using-flask-restx/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/using-flask-restx/Flask-JWT-Extended.postman_collection.json ================================================ { "info": { "_postman_id": "74a1833f-bc4e-4e85-a525-72d268ab9999", "name": "Flask-JWT-Extended", "description": "This collection contains requests associated witht the Flask-JWT-Extended section of the REST API course.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "users", "description": "", "item": [ { "name": "register a new user", "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/register", "host": [ "{{server_address}}" ], "path": [ "register" ] } }, "response": [] }, { "name": "get user by id", "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/user/1", "host": [ "{{server_address}}" ], "path": [ "user", "1" ] } }, "response": [] }, { "name": "delete user by id", "request": { "method": "DELETE", "header": [], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/user/2", "host": [ "{{server_address}}" ], "path": [ "user", "2" ] } }, "response": [] }, { "name": "login", "event": [ { "listen": "test", "script": { "id": "8c0c0ed6-c206-4c88-9349-429e024e312b", "type": "text/javascript", "exec": [ "var jsonData = pm.response.json();", "pm.test(\"access_token not empty\", function () {", " pm.expect(jsonData.access_token).not.eql(undefined);", "});", "", "pm.test(\"refresh token not empty\", function () {", " pm.expect(jsonData.refresh_token).not.eql(undefined);", "});", "// set access token as environement variable", "if (jsonData.access_token !== undefined) {", " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", "} else {", " postman.setEnvironmentVariable(\"access_token\", null);", "}", "// set refresh token as environement variable", "if (jsonData.refresh_token !== undefined) {", " postman.setEnvironmentVariable(\"refresh_token\", jsonData.refresh_token);", "} else {", " postman.setEnvironmentVariable(\"refresh_token\", null);", "}" ] } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"username\" : \"jose\",\n \"password\" : \"1234\"\n}" }, "url": { "raw": "{{server_address}}/login", "host": [ "{{server_address}}" ], "path": [ "login" ] } }, "response": [] }, { "name": "logout", "event": [ { "listen": "test", "script": { "id": "dc763e9b-e6c7-4ff3-9766-637976a5c64b", "type": "text/javascript", "exec": [ "" ] } } ], "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{server_address}}/logout", "host": [ "{{server_address}}" ], "path": [ "logout" ] } }, "response": [] }, { "name": "refresh token", "event": [ { "listen": "test", "script": { "id": "ad818ea6-8f79-436e-b756-ad878666ae9e", "type": "text/javascript", "exec": [ "var jsonData = pm.response.json();", "pm.test(\"access_token not empty\", function () {", " pm.expect(jsonData.access_token).not.eql(undefined);", "});", "// set access token as environement variable", "if (jsonData.access_token !== undefined) {", " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", "} else {", " postman.setEnvironmentVariable(\"access_token\", null);", "}" ] } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{refresh_token}}" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{local_flask}}/refresh", "host": [ "{{local_flask}}" ], "path": [ "refresh" ] } }, "response": [] } ] }, { "name": "items", "description": "", "item": [ { "name": "get item/name", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": {}, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "post item/name", "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "put item/name", "request": { "method": "PUT", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "delete item by name", "request": { "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "get all items", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": { "mode": "raw", "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], "path": [ "items" ] } }, "response": [] }, { "name": "get all items without JWT", "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], "path": [ "items" ] } }, "response": [] } ] }, { "name": "stores", "description": "", "item": [ { "name": "create a new store", "request": { "method": "POST", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "get store by name", "request": { "method": "GET", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "delete a new store by name", "request": { "method": "DELETE", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "get all stores", "request": { "method": "GET", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store", "host": [ "{{server_address}}" ], "path": [ "stores" ] } }, "response": [] } ] } ] } ================================================ FILE: project/using-flask-restx/Stores_REST_API_2022-01-14.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-01-14T11:50:51.742Z","__export_source":"insomnia.desktop.app:v2021.7.2","resources":[{"_id":"req_efcadee1c4fc48f099644e23398a5d29","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159057139,"created":1642157007062,"url":"{{url}}/register","name":"/register","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_64c3752c7f694f0aa830bacba3b35aea"},{"name":"Authorization","value":"JWT","id":"pair_d143b36c4aa74f9681dc1590970da3b7"}],"authentication":{},"metaSortKey":-1642157660252,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_fd1f956aae16470fafdc3d611d34a80a","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157670592,"created":1642157670592,"name":"Authentication","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157670592,"_type":"request_group"},{"_id":"wrk_c441bf446d174d1bb2f01c7ad66c695b","parentId":null,"modified":1642157007080,"created":1642149963161,"name":"Stores REST API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_16415c75944342dab73119513e7bd20b","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159087108,"created":1642157007061,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_a8caec1064eb43b7ac5c8c9294be13a3"}],"authentication":{},"metaSortKey":-1642157660202,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b08c51961bea4413a31fba1af93b3759","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642159093249,"created":1642157007054,"url":"{{url}}/item/my_item","name":"/item/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": 3\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"},{"id":"pair_4926e48dcb594eaa9c79a78b801b708f","name":"Authorization","value":"Bearer {% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'always', 60 %}","description":""}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_5bf669c32c3145a3a80dee2d6523f9ac","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157649712,"created":1642157649712,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157649712,"_type":"request_group"},{"_id":"req_e553e5091f714becb81e1b27bfc8f34b","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642160992156,"created":1642157007053,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"Bearer {{access_token}}","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f64b6c8fc8642aa9c267c8d49c72435","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157976127,"created":1642157007052,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_62498017fcb34ba0a3a19b4e0f2d4499","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69d7ed86b4dc4b72a72778f97a77e05c","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157658823,"created":1642157007048,"url":"{{url}}/item","name":"/item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007178.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_054be716de114cc49d6e49d04a5a901b","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642159101503,"created":1642157684047,"url":"{{url}}/tag/my_tag","name":"/tag/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"item_id\": {% response 'body', 'req_e553e5091f714becb81e1b27bfc8f34b', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_adac84f9834d4e948ceb02807787c935","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157684028,"created":1642157684028,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157641282.5,"_type":"request_group"},{"_id":"req_a5b2631adf9e4ef894a8a1b9d2c77aa8","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158069229,"created":1642157684046,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"JWT ","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5dbef7a9b30d4ee68c148c4af447c241","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158073367,"created":1642157684041,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7e8e3838e1cb41b485e091bf667b0764","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157645538,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320140.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157632853,"created":1642157632853,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157632853,"_type":"request_group"},{"_id":"req_f99ce0192797434f99657221acc45fe3","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157643850,"created":1642157007059,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320090.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82705a91a36849b09f1347d135816761","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157642682,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320040.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94efb5c0488d43d8be95fd82b33afb97","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157639015,"created":1642157007060,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157319990.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9bc4db6f4d02466aba86edef29722854","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157781373,"created":1642157007070,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_8c8f8b7b9ddd4c3ca7fb6df5418b7f2e"}],"authentication":{},"metaSortKey":-1642157007070,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_05fce0fdc405492d8a7f87842e6d4e13","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157017423,"created":1642157007072,"name":"User create store and item","description":"Check user can register.\nCheck user can create store.\nCheck user can create item in store.","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157007128,"_type":"request_group"},{"_id":"req_2ce4ecd840094ac1a164d7a0bfbb6d83","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157887543,"created":1642157007069,"url":"{{url}}/store/test_store","name":"/store/test_store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007069,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8c68f13e77a74937b62cde1ae24bed61","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157923539,"created":1642157007068,"url":"{{url}}/item/test_item","name":"/item/test_item in test_store","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": {% response 'body', 'req_2ce4ecd840094ac1a164d7a0bfbb6d83', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_d0c5451b8b044511b76436c627ffc4bb"},{"id":"pair_eb8ca7f686334ae5a48ad48412436ad9","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007068,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_297f6099ee274c1f8ccceb3bc29ad582","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007065,"created":1642157007065,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007065,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84a807bef7cd4a66bc81f5401f0639cd","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157931016,"created":1642157007064,"url":"{{url}}/item/test_item","name":"/item/my_item copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_daca8133eb94474ca84748a0e4c8bcaf","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007064,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_10076b1332f2458e897d7b5200c7e5de","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007063,"created":1642157007063,"url":"{{url}}/store/test_store","name":"/store/ copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007063,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642160933906,"created":1642149963165,"name":"Base Environment","data":{"url":"http://127.0.0.1:5000","access_token":"{% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'never', 60 %}"},"dataPropertyOrder":{"&":["url","access_token"]},"color":null,"isPrivate":false,"metaSortKey":1642149963165,"_type":"environment"},{"_id":"jar_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963166,"created":1642149963166,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_f25b8aff2219447aa56189a385b1663c","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963162,"created":1642149963162,"fileName":"Stores REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: project/using-flask-restx/app.py ================================================ from flask import Flask, jsonify from flask_restx import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import api as user_namespace from resources.item import api as item_namespace from resources.store import api as store_namespace from resources.tag import api as tag_namespace app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) """ JWT related configuration. The following functions includes: 1) add claims to each jwt 2) customize the token expired error message """ app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) """ `claims` are data we choose to attach to each jwt payload and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` one possible use case for claims are access level control, which is shown below """ @jwt.additional_claims_loader def add_claims_to_jwt(identity): # TODO: Read from a config file instead of hard-coding if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return jsonify({"message": "The token has expired.", "error": "token_expired"}), 401 @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token is not fresh.", "error": "fresh_token_required"} ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.add_namespace(user_namespace, path="/") api.add_namespace(item_namespace, path="/item") api.add_namespace(store_namespace, path="/store") api.add_namespace(tag_namespace, path="/tag") ================================================ FILE: project/using-flask-restx/blocklist.py ================================================ """ blacklist.py This file just contains the blacklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blacklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: project/using-flask-restx/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: project/using-flask-restx/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: project/using-flask-restx/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") def json(self): return { "id": self.id, "name": self.name, "price": self.price, "store_id": self.store_id, "tags": [tag.json() for tag in self.tags], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restx/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: project/using-flask-restx/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") def json(self): return { "id": self.id, "name": self.name, "items": [item.json() for item in self.items.all()], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restx/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") def json(self): return { "id": self.id, "name": self.name, "items": [item.name for item in self.items], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restx/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) def json(self): return { 'id': self.id, 'username': self.username } @classmethod def find_by_username(cls, username): return cls.query.filter_by(username=username).first() @classmethod def find_by_id(cls, _id): return cls.query.filter_by(id=_id).first() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-restx/requirements.txt ================================================ Flask-JWT-Extended Flask-RESTX Flask-SQLAlchemy passlib marshmallow python-dotenv ================================================ FILE: project/using-flask-restx/resources/__init__.py ================================================ ================================================ FILE: project/using-flask-restx/resources/item.py ================================================ from flask import request from flask_restx import Namespace, Resource, fields, abort from flask_jwt_extended import jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from models import ItemModel api = Namespace("items", description="Operations related to store items.") item_inputs = api.model( "ItemFields", { "price": fields.Float(required=True, description="A price for this item."), "store_id": fields.Integer( required=True, description="The identifier for the store that this item belongs to.", ), }, ) nested_resource = api.model( "NestedResource", {"id": fields.Integer(), "name": fields.String()} ) item_outputs = api.inherit( "Item", item_inputs, { "id": fields.Integer(), "name": fields.String(), "store": fields.Nested(nested_resource), "tags": fields.List(fields.Nested(nested_resource)), }, ) @api.route("/") @api.param("name", "The unique name for the item you want to interact with.") @api.doc( responses={ 404: "Item not found.", 400: "Bad request (name already exists or validation error).", 500: "An error occurred while inserting that item.", } ) class Item(Resource): @jwt_required() @api.marshal_with(item_outputs) def get(self, name): item = ItemModel.find_by_name(name) if item: return item abort(404, "Item not found") @jwt_required(fresh=True) @api.expect(item_inputs, validate=True) @api.marshal_with(item_outputs) def post(self, name): if ItemModel.find_by_name(name): abort(400, f"An item with name {name} already exists.") item = ItemModel(name=name, **request.get_json()) try: item.save_to_db() except SQLAlchemyError: abort(500, "An error occurred while inserting the item.") return item, 201 @jwt_required() def delete(self, name): jwt = get_jwt() if not jwt["is_admin"]: abort(401, "Admin privilege required.") item = ItemModel.find_by_name(name) if item: item.delete_from_db() return {"message": "Item deleted."} abort(404, "Item not found.") @api.expect(item_inputs, validate=True) @api.marshal_with(item_outputs) def put(self, name): item = ItemModel.find_by_name(name) if item: item.price = request.get_json()["price"] else: item = ItemModel(name, **request.get_json()) item.save_to_db() return item @api.route("/") class ItemList(Resource): @api.marshal_list_with(item_outputs) def get(self): items = ItemModel.find_all() return items, 200 ================================================ FILE: project/using-flask-restx/resources/store.py ================================================ from flask import abort from flask_restx import Namespace, Resource, fields from sqlalchemy.exc import SQLAlchemyError from models import StoreModel api = Namespace("stores", description="Operations related to stores.") nested_item = api.model( "NestedItem", { "id": fields.Integer(), "name": fields.String(), "price": fields.Float(), }, ) store_outputs = api.model( "Store", { "id": fields.Integer(), "name": fields.String(), "items": fields.List(fields.Nested(nested_item)), }, ) @api.route("/") class Store(Resource): @api.marshal_with(store_outputs) def get(self, name): store = StoreModel.find_by_name(name) if store: return store abort(404, "Store not found.") @api.marshal_with(store_outputs) def post(self, name): if StoreModel.find_by_name(name): abort(400, f"A store with name '{name}' already exists.") store = StoreModel(name=name) try: store.save_to_db() except SQLAlchemyError: abort(500, "An error occurred creating the store.") return store, 201 def delete(self, name): store = StoreModel.find_by_name(name) if store: store.delete_from_db() return {"message": "Store deleted"}, 200 abort(404, "Store not found.") @api.route("/") class StoreList(Resource): @api.marshal_list_with(store_outputs) def get(self): return StoreModel.find_all() ================================================ FILE: project/using-flask-restx/resources/tag.py ================================================ from flask import abort, request from flask_restx import Namespace, Resource, fields from werkzeug.exceptions import BadRequest from sqlalchemy.exc import SQLAlchemyError from models import TagModel from models import ItemModel api = Namespace( "tags", description="Operations related to tags and their relationship to items." ) item_id = api.model("ItemId", {"item_id": fields.Integer()}) nested_item = api.inherit( "NestedItem", item_id, { "name": fields.String(), "price": fields.Float(), }, ) nested_tag = api.model( "NestedTag", { "id": fields.Integer(), "name": fields.String(), }, ) tag_outputs = api.inherit( "Tag", nested_tag, { "items": fields.List(fields.Nested(nested_item)), }, ) @api.route("/") class Tag(Resource): @api.marshal_with(tag_outputs) def get(self, name): tag = TagModel.find_by_name(name) if tag: return tag abort(404, "Tag not found.") @api.marshal_with(tag_outputs) def post(self, name): json_input = request.get_json() tag = TagModel.find_by_name(name) if not tag: tag = TagModel(name=name) # Add the item to the tag try: item = ItemModel.query.get(json_input["item_id"]) if not item: abort(400, "An item with this item_id doesn't exist.") tag.items.append(item) except (TypeError, KeyError): abort(400, "Missing required field 'item_id' in JSON body.") try: tag.save_to_db() except SQLAlchemyError: abort(500, "An error occurred while inserting the tag.") return tag, 201 def delete(self, name): tag = TagModel.find_by_name(name) if not tag: abort(404, "Tag not found.") if not tag.items: tag.delete_from_db() return {"message": f"Tag '{name}' deleted."} abort( 400, "Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) @api.route("//remove") class RemoveItemFromTag(Resource): @api.expect(item_id, validate=True) def delete(self, name): tag = TagModel.find_by_name(name) if not tag: abort(404, "Tag not found.") try: item_id = request.get_json()["item_id"] item = ItemModel.query.get(item_id) try: tag.items.remove(item) except ValueError: abort( 400, f"Could not remove item with id '{item_id}' from tag." "Make sure item is associated with that item.", ) return {"message": f"Item with id '{item_id}' removed from tag."} except BadRequest: abort( 400, "Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) @api.route("/") class TagList(Resource): @api.marshal_list_with(tag_outputs) def get(self): return TagModel.find_all() ================================================ FILE: project/using-flask-restx/resources/user.py ================================================ from flask import abort, request from flask_restx import Namespace, Resource, fields from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from models import UserModel from blocklist import BLOCKLIST api = Namespace("users", description="Operations related to users and authentication.") user_inputs = api.model( "UserFields", { "username": fields.String(required=True), "password": fields.String(required=True), }, ) user_outputs = api.model("User", {"id": fields.String(), "username": fields.String()}) @api.route("/register") class UserRegister(Resource): @api.expect(user_inputs, validate=True) def post(self): user_data = request.get_json() if UserModel.find_by_username(user_data["username"]): abort(400, "A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) user.save_to_db() return {"message": "User created successfully."}, 201 @api.route("/login") class UserLogin(Resource): @api.expect(user_inputs, validate=True) def post(self): user_data = request.get_json() user = UserModel.find_by_username(user_data["username"]) if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, "Invalid credentials.") @api.route("/logout") class UserLogout(Resource): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @api.route("/user/") class User(Resource): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @api.marshal_with(user_outputs) def get(cls, user_id): user = UserModel.find_by_id(user_id) if not user: abort(404, "User not found.") return user, 200 def delete(self, user_id): user = UserModel.find_by_id(user_id) if not user: abort(404, "User not found.") user.delete_from_db() return {"message": "User deleted."}, 200 @api.route("/refresh") class TokenRefresh(Resource): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token}, 200 ================================================ FILE: project/using-flask-smorest/.flaskenv ================================================ FLASK_APP=app FLASK_DEBUG=True ================================================ FILE: project/using-flask-smorest/Flask-JWT-Extended.postman_collection.json ================================================ { "info": { "_postman_id": "74a1833f-bc4e-4e85-a525-72d268ab9999", "name": "Flask-JWT-Extended", "description": "This collection contains requests associated witht the Flask-JWT-Extended section of the REST API course.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "users", "description": "", "item": [ { "name": "register a new user", "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/register", "host": [ "{{server_address}}" ], "path": [ "register" ] } }, "response": [] }, { "name": "get user by id", "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/user/1", "host": [ "{{server_address}}" ], "path": [ "user", "1" ] } }, "response": [] }, { "name": "delete user by id", "request": { "method": "DELETE", "header": [], "body": { "mode": "raw", "raw": "{\n\t\"username\": \"jose\",\n\t\"password\": \"1234\"\n}" }, "url": { "raw": "{{server_address}}/user/2", "host": [ "{{server_address}}" ], "path": [ "user", "2" ] } }, "response": [] }, { "name": "login", "event": [ { "listen": "test", "script": { "id": "8c0c0ed6-c206-4c88-9349-429e024e312b", "type": "text/javascript", "exec": [ "var jsonData = pm.response.json();", "pm.test(\"access_token not empty\", function () {", " pm.expect(jsonData.access_token).not.eql(undefined);", "});", "", "pm.test(\"refresh token not empty\", function () {", " pm.expect(jsonData.refresh_token).not.eql(undefined);", "});", "// set access token as environement variable", "if (jsonData.access_token !== undefined) {", " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", "} else {", " postman.setEnvironmentVariable(\"access_token\", null);", "}", "// set refresh token as environement variable", "if (jsonData.refresh_token !== undefined) {", " postman.setEnvironmentVariable(\"refresh_token\", jsonData.refresh_token);", "} else {", " postman.setEnvironmentVariable(\"refresh_token\", null);", "}" ] } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"username\" : \"jose\",\n \"password\" : \"1234\"\n}" }, "url": { "raw": "{{server_address}}/login", "host": [ "{{server_address}}" ], "path": [ "login" ] } }, "response": [] }, { "name": "logout", "event": [ { "listen": "test", "script": { "id": "dc763e9b-e6c7-4ff3-9766-637976a5c64b", "type": "text/javascript", "exec": [ "" ] } } ], "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{server_address}}/logout", "host": [ "{{server_address}}" ], "path": [ "logout" ] } }, "response": [] }, { "name": "refresh token", "event": [ { "listen": "test", "script": { "id": "ad818ea6-8f79-436e-b756-ad878666ae9e", "type": "text/javascript", "exec": [ "var jsonData = pm.response.json();", "pm.test(\"access_token not empty\", function () {", " pm.expect(jsonData.access_token).not.eql(undefined);", "});", "// set access token as environement variable", "if (jsonData.access_token !== undefined) {", " postman.setEnvironmentVariable(\"access_token\", jsonData.access_token);", "} else {", " postman.setEnvironmentVariable(\"access_token\", null);", "}" ] } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{refresh_token}}" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{local_flask}}/refresh", "host": [ "{{local_flask}}" ], "path": [ "refresh" ] } }, "response": [] } ] }, { "name": "items", "description": "", "item": [ { "name": "get item/name", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": {}, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "post item/name", "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "put item/name", "request": { "method": "PUT", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"price\": 12.99,\n \"store_id\": 1\n}" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "delete item by name", "request": { "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "" }, "url": { "raw": "{{local_flask}}/item/chair", "host": [ "{{local_flask}}" ], "path": [ "item", "chair" ] } }, "response": [] }, { "name": "get all items", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{access_token}}" } ], "body": { "mode": "raw", "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], "path": [ "items" ] } }, "response": [] }, { "name": "get all items without JWT", "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "{\n \"username\" : \"cristiano\",\n \"password\" : \"12345678\"\n}" }, "url": { "raw": "{{local_flask}}/item", "host": [ "{{local_flask}}" ], "path": [ "items" ] } }, "response": [] } ] }, { "name": "stores", "description": "", "item": [ { "name": "create a new store", "request": { "method": "POST", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "get store by name", "request": { "method": "GET", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "delete a new store by name", "request": { "method": "DELETE", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store/My Wonderful Store", "host": [ "{{server_address}}" ], "path": [ "store", "My Wonderful Store" ] } }, "response": [] }, { "name": "get all stores", "request": { "method": "GET", "header": [], "body": {}, "url": { "raw": "{{server_address}}/store", "host": [ "{{server_address}}" ], "path": [ "stores" ] } }, "response": [] } ] } ] } ================================================ FILE: project/using-flask-smorest/Stores_REST_API_2022-01-14.json ================================================ {"_type":"export","__export_format":4,"__export_date":"2022-01-14T11:50:51.742Z","__export_source":"insomnia.desktop.app:v2021.7.2","resources":[{"_id":"req_efcadee1c4fc48f099644e23398a5d29","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159057139,"created":1642157007062,"url":"{{url}}/register","name":"/register","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_64c3752c7f694f0aa830bacba3b35aea"},{"name":"Authorization","value":"JWT","id":"pair_d143b36c4aa74f9681dc1590970da3b7"}],"authentication":{},"metaSortKey":-1642157660252,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_fd1f956aae16470fafdc3d611d34a80a","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157670592,"created":1642157670592,"name":"Authentication","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157670592,"_type":"request_group"},{"_id":"wrk_c441bf446d174d1bb2f01c7ad66c695b","parentId":null,"modified":1642157007080,"created":1642149963161,"name":"Stores REST API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_16415c75944342dab73119513e7bd20b","parentId":"fld_fd1f956aae16470fafdc3d611d34a80a","modified":1642159087108,"created":1642157007061,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_a8caec1064eb43b7ac5c8c9294be13a3"}],"authentication":{},"metaSortKey":-1642157660202,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_b08c51961bea4413a31fba1af93b3759","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642159093249,"created":1642157007054,"url":"{{url}}/item/my_item","name":"/item/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": 3\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"},{"id":"pair_4926e48dcb594eaa9c79a78b801b708f","name":"Authorization","value":"Bearer {% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'always', 60 %}","description":""}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_5bf669c32c3145a3a80dee2d6523f9ac","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157649712,"created":1642157649712,"name":"Items","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157649712,"_type":"request_group"},{"_id":"req_e553e5091f714becb81e1b27bfc8f34b","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642160992156,"created":1642157007053,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"Bearer {{access_token}}","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_1f64b6c8fc8642aa9c267c8d49c72435","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157976127,"created":1642157007052,"url":"{{url}}/item/my_item","name":"/item/my_item","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_62498017fcb34ba0a3a19b4e0f2d4499","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_69d7ed86b4dc4b72a72778f97a77e05c","parentId":"fld_5bf669c32c3145a3a80dee2d6523f9ac","modified":1642157658823,"created":1642157007048,"url":"{{url}}/item","name":"/item","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007178.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_054be716de114cc49d6e49d04a5a901b","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642159101503,"created":1642157684047,"url":"{{url}}/tag/my_tag","name":"/tag/","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"item_id\": {% response 'body', 'req_e553e5091f714becb81e1b27bfc8f34b', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_9ded5c5a9c7e452386a15e8cc29bdcab"}],"authentication":{},"metaSortKey":-1642157007278.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_adac84f9834d4e948ceb02807787c935","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157684028,"created":1642157684028,"name":"Tags","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157641282.5,"_type":"request_group"},{"_id":"req_a5b2631adf9e4ef894a8a1b9d2c77aa8","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158069229,"created":1642157684046,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"Authorization","value":"JWT ","id":"pair_593ef7235a0d4f73b2fd09bd50f6c0c7"}],"authentication":{},"metaSortKey":-1642157007253.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5dbef7a9b30d4ee68c148c4af447c241","parentId":"fld_adac84f9834d4e948ceb02807787c935","modified":1642158073367,"created":1642157684041,"url":"{{url}}/tag/my_tag","name":"/tag/my_tag","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007228.3906,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7e8e3838e1cb41b485e091bf667b0764","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157645538,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320140.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157632853,"created":1642157632853,"name":"Stores","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157632853,"_type":"request_group"},{"_id":"req_f99ce0192797434f99657221acc45fe3","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157643850,"created":1642157007059,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320090.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_82705a91a36849b09f1347d135816761","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157642682,"created":1642157007056,"url":"{{url}}/store/my_store","name":"/store/","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157320040.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_94efb5c0488d43d8be95fd82b33afb97","parentId":"fld_d76a6a294c734ea9bbc6fedf33cc29eb","modified":1642157639015,"created":1642157007060,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157319990.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_9bc4db6f4d02466aba86edef29722854","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157781373,"created":1642157007070,"url":"{{url}}/login","name":"/auth","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"username\": \"user1\",\n\t\"password\": \"abcxyz\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_8c8f8b7b9ddd4c3ca7fb6df5418b7f2e"}],"authentication":{},"metaSortKey":-1642157007070,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_05fce0fdc405492d8a7f87842e6d4e13","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642157017423,"created":1642157007072,"name":"User create store and item","description":"Check user can register.\nCheck user can create store.\nCheck user can create item in store.","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1642157007128,"_type":"request_group"},{"_id":"req_2ce4ecd840094ac1a164d7a0bfbb6d83","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157887543,"created":1642157007069,"url":"{{url}}/store/test_store","name":"/store/test_store","description":"","method":"POST","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007069,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8c68f13e77a74937b62cde1ae24bed61","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157923539,"created":1642157007068,"url":"{{url}}/item/test_item","name":"/item/test_item in test_store","description":"","method":"POST","body":{"mimeType":"","text":"{\n\t\"price\": 17.99,\n\t\"store_id\": {% response 'body', 'req_2ce4ecd840094ac1a164d7a0bfbb6d83', 'b64::JC5pZA==::46b', 'never', 60 %}\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_d0c5451b8b044511b76436c627ffc4bb"},{"id":"pair_eb8ca7f686334ae5a48ad48412436ad9","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007068,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_297f6099ee274c1f8ccceb3bc29ad582","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007065,"created":1642157007065,"url":"{{url}}/store","name":"/store","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007065,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_84a807bef7cd4a66bc81f5401f0639cd","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157931016,"created":1642157007064,"url":"{{url}}/item/test_item","name":"/item/my_item copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[{"id":"pair_daca8133eb94474ca84748a0e4c8bcaf","name":"Authorization","value":"Bearer {{access_token}}","description":""}],"authentication":{},"metaSortKey":-1642157007064,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_10076b1332f2458e897d7b5200c7e5de","parentId":"fld_05fce0fdc405492d8a7f87842e6d4e13","modified":1642157007063,"created":1642157007063,"url":"{{url}}/store/test_store","name":"/store/ copy","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1642157007063,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642160933906,"created":1642149963165,"name":"Base Environment","data":{"url":"http://127.0.0.1:5000","access_token":"{% response 'body', 'req_16415c75944342dab73119513e7bd20b', 'b64::JC5hY2Nlc3NfdG9rZW4=::46b', 'never', 60 %}"},"dataPropertyOrder":{"&":["url","access_token"]},"color":null,"isPrivate":false,"metaSortKey":1642149963165,"_type":"environment"},{"_id":"jar_34cb01359e95568602d0f3f1a1c4d42a45b00dc5","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963166,"created":1642149963166,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_f25b8aff2219447aa56189a385b1663c","parentId":"wrk_c441bf446d174d1bb2f01c7ad66c695b","modified":1642149963162,"created":1642149963162,"fileName":"Stores REST API","contents":"","contentType":"yaml","_type":"api_spec"}]} ================================================ FILE: project/using-flask-smorest/app.py ================================================ from flask import Flask, jsonify from flask_smorest import Api from flask_jwt_extended import JWTManager from db import db from blocklist import BLOCKLIST from resources.user import blp as UserBlueprint from resources.item import blp as ItemBlueprint from resources.store import blp as StoreBlueprint from resources.tag import blp as TagBlueprint app = Flask(__name__) app.config["API_TITLE"] = "Stores REST API" app.config["API_VERSION"] = "v1" app.config["OPENAPI_VERSION"] = "3.0.3" app.config["OPENAPI_URL_PREFIX"] = "/" app.config["OPENAPI_SWAGGER_UI_PATH"] = "/swagger-ui" app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["PROPAGATE_EXCEPTIONS"] = True db.init_app(app) api = Api(app) """ JWT related configuration. The following functions includes: 1) add claims to each jwt 2) customize the token expired error message """ app.config["JWT_SECRET_KEY"] = "jose" jwt = JWTManager(app) """ `claims` are data we choose to attach to each jwt payload and for each jwt protected endpoint, we can retrieve these claims via `get_jwt_claims()` one possible use case for claims are access level control, which is shown below """ @jwt.additional_claims_loader def add_claims_to_jwt(identity): # TODO: Read from a config file instead of hard-coding if identity == 1: return {"is_admin": True} return {"is_admin": False} @jwt.token_in_blocklist_loader def check_if_token_in_blocklist(jwt_header, jwt_payload): return jwt_payload["jti"] in BLOCKLIST @jwt.expired_token_loader def expired_token_callback(jwt_header, jwt_payload): return jsonify({"message": "The token has expired.", "error": "token_expired"}), 401 @jwt.invalid_token_loader def invalid_token_callback(error): return ( jsonify( {"message": "Signature verification failed.", "error": "invalid_token"} ), 401, ) @jwt.unauthorized_loader def missing_token_callback(error): return ( jsonify( { "description": "Request does not contain an access token.", "error": "authorization_required", } ), 401, ) @jwt.needs_fresh_token_loader def token_not_fresh_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token is not fresh.", "error": "fresh_token_required"} ), 401, ) @jwt.revoked_token_loader def revoked_token_callback(jwt_header, jwt_payload): return ( jsonify( {"description": "The token has been revoked.", "error": "token_revoked"} ), 401, ) # JWT configuration ends with app.app_context(): import models # noqa: F401 db.create_all() api.register_blueprint(UserBlueprint) api.register_blueprint(ItemBlueprint) api.register_blueprint(StoreBlueprint) api.register_blueprint(TagBlueprint) ================================================ FILE: project/using-flask-smorest/blocklist.py ================================================ """ blacklist.py This file just contains the blacklist of the JWT tokens. It will be imported by app and the logout resource so that tokens can be added to the blacklist when the user logs out. """ BLOCKLIST = set() ================================================ FILE: project/using-flask-smorest/db.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() ================================================ FILE: project/using-flask-smorest/models/__init__.py ================================================ from models.user import UserModel from models.item import ItemModel from models.tag import TagModel from models.store import StoreModel from models.item_tags import ItemsTags ================================================ FILE: project/using-flask-smorest/models/item.py ================================================ from db import db class ItemModel(db.Model): __tablename__ = "items" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) price = db.Column(db.Float(precision=2), unique=False, nullable=False) store_id = db.Column( db.Integer, db.ForeignKey("stores.id"), unique=False, nullable=False ) store = db.relationship("StoreModel", back_populates="items") tags = db.relationship("TagModel", back_populates="items", secondary="items_tags") def json(self): return { "id": self.id, "name": self.name, "price": self.price, "store_id": self.store_id, "tags": [tag.json() for tag in self.tags], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-smorest/models/item_tags.py ================================================ from db import db class ItemsTags(db.Model): __tablename__ = "items_tags" id = db.Column(db.Integer, primary_key=True) item_id = db.Column(db.Integer, db.ForeignKey("items.id")) tag_id = db.Column(db.Integer, db.ForeignKey("tags.id")) ================================================ FILE: project/using-flask-smorest/models/store.py ================================================ from db import db class StoreModel(db.Model): __tablename__ = "stores" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) items = db.relationship("ItemModel", back_populates="store", lazy="dynamic") def json(self): return { "id": self.id, "name": self.name, "items": [item.json() for item in self.items.all()], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-smorest/models/tag.py ================================================ from db import db class TagModel(db.Model): __tablename__ = "tags" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) items = db.relationship("ItemModel", back_populates="tags", secondary="items_tags") def json(self): return { "id": self.id, "name": self.name, "items": [item.name for item in self.items], } @classmethod def find_by_name(cls, name): return cls.query.filter_by(name=name).first() @classmethod def find_all(cls): return cls.query.all() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-smorest/models/user.py ================================================ from db import db class UserModel(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password = db.Column(db.String(80), nullable=False) def json(self): return { 'id': self.id, 'username': self.username } @classmethod def find_by_username(cls, username): return cls.query.filter_by(username=username).first() @classmethod def find_by_id(cls, _id): return cls.query.filter_by(id=_id).first() def save_to_db(self): db.session.add(self) db.session.commit() def delete_from_db(self): db.session.delete(self) db.session.commit() ================================================ FILE: project/using-flask-smorest/requirements.txt ================================================ Flask-JWT-Extended Flask-Smorest Flask-SQLAlchemy passlib marshmallow python-dotenv ================================================ FILE: project/using-flask-smorest/resources/__init__.py ================================================ ================================================ FILE: project/using-flask-smorest/resources/item.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import get_jwt_identity, jwt_required, get_jwt from sqlalchemy.exc import SQLAlchemyError from models import ItemModel from schemas import ItemSchema, ItemUpdateSchema blp = Blueprint("Items", __name__, description="Operations on items") @blp.route("/item/") class Item(MethodView): @jwt_required() @blp.response(200, ItemSchema) def get(self, name): item = ItemModel.find_by_name(name) if item: return item abort(404, message="Item not found") @jwt_required(fresh=True) @blp.arguments(ItemSchema) @blp.response(201, ItemSchema) def post(self, item_data, name): if ItemModel.find_by_name(name): abort(400, message=f"An item with name {name} already exists.") item = ItemModel(**item_data, name=name) try: item.save_to_db() except SQLAlchemyError: abort(500, message="An error occurred while inserting the item.") return item @jwt_required() def delete(self, name): jwt = get_jwt() if not jwt["is_admin"]: abort(401, message="Admin privilege required.") item = ItemModel.find_by_name(name) if item: item.delete_from_db() return {"message": "Item deleted."} abort(404, message="Item not found.") @blp.arguments(ItemUpdateSchema) @blp.response(200, ItemSchema) def put(self, item_data, name): item = ItemModel.find_by_name(name) if item: item.price = item_data["price"] else: item = ItemModel(name, **item_data) item.save_to_db() return item @blp.route("/item") class ItemList(MethodView): @jwt_required() @blp.response(200, ItemSchema(many=True)) def get(self): return ItemModel.find_all() ================================================ FILE: project/using-flask-smorest/resources/store.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from sqlalchemy.exc import SQLAlchemyError from models import StoreModel from schemas import StoreSchema blp = Blueprint("Stores", __name__, description="Operations on stores") @blp.route("/store/") class Store(MethodView): @blp.response(200, StoreSchema) def get(cls, name): store = StoreModel.find_by_name(name) if store: return store abort(404, message="Store not found.") @blp.response(201, StoreSchema) def post(cls, name): if StoreModel.find_by_name(name): abort(400, message=f"A store with name '{name}' already exists.") store = StoreModel(name=name) try: store.save_to_db() except SQLAlchemyError: abort(500, message="An error occurred creating the store.") return store def delete(cls, name): store = StoreModel.find_by_name(name) if store: store.delete_from_db() return {"message": "Store deleted"}, 200 abort(404, message="Store not found.") @blp.route("/store") class StoreList(MethodView): @blp.response(200, StoreSchema(many=True)) def get(cls): return StoreModel.find_all() ================================================ FILE: project/using-flask-smorest/resources/tag.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from werkzeug.exceptions import BadRequest from sqlalchemy.exc import SQLAlchemyError from models import TagModel from models import ItemModel from schemas import TagSchema, TagUpdateSchema, TagAndItemSchema blp = Blueprint("Tags", "tags", description="Operations on tags") @blp.route("/tag/") class Tag(MethodView): @blp.response(200, TagSchema) def get(self, name): tag = TagModel.find_by_name(name) if tag: return tag abort(404, message="Tag not found.") @blp.arguments(TagUpdateSchema) @blp.response(201, TagSchema) def post(self, update_data, name): tag = TagModel.find_by_name(name) if not tag: tag = TagModel(name=name) # Add the item to the tag try: item = ItemModel.query.get(update_data["item_id"]) if not item: abort(400, message="An item with this item_id doesn't exist.") tag.items.append(item) except (TypeError, KeyError): abort(400, message="Missing required field 'item_id' in JSON body.") try: tag.save_to_db() except SQLAlchemyError: abort(500, message="An error occurred while inserting the tag.") return tag @blp.arguments(TagUpdateSchema, required=False) @blp.response(200, TagAndItemSchema) @blp.alt_response( 202, description="Deletes a tag when it has no items and no item_id is passed in the body.", example={"message": "Tag deleted."}, success=True, ) @blp.alt_response(404, description="Tag not found") @blp.alt_response( 400, description="Missing item_id in body when tag is associated to items." ) def delete(self, tag_data, name): """Deletes a tag. If the tag is associated to items, expects an item_id in the JSON body and unlinks the item from the tag. If the tag is not associated to any items, then does not expect item_id in the JSON body and deletes the tag entirely. """ tag = TagModel.find_by_name(name) if "item_id" in tag_data: item = ItemModel.query.get(tag_data["item_id"]) tag.items.remove(item) tag.save_to_db() return { "message": "Item removed from tag", "item": item, "tag": tag, } else: # Assume no item_id was passed. Instead delete entire tag. # First check tag has no items if not tag.items: tag.delete_from_db() return {"message": "Tag deleted."} abort( 400, message="Could not delete tag. Make sure tag is not associated with any items, then try again.", # noqa: E501 ) abort(404, message="Tag not found.") ================================================ FILE: project/using-flask-smorest/resources/user.py ================================================ from flask.views import MethodView from flask_smorest import Blueprint, abort from flask_jwt_extended import ( create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required, ) from passlib.hash import pbkdf2_sha256 from models import UserModel from schemas import UserSchema from blocklist import BLOCKLIST blp = Blueprint("Users", "users", description="Operations on users") @blp.route("/register") class UserRegister(MethodView): @blp.arguments(UserSchema) def post(self, user_data): if UserModel.find_by_username(user_data["username"]): abort(400, message="A user with that username already exists.") user = UserModel( username=user_data["username"], password=pbkdf2_sha256.hash(user_data["password"]), ) user.save_to_db() return {"message": "User created successfully."}, 201 @blp.route("/login") class UserLogin(MethodView): @blp.arguments(UserSchema) def post(self, user_data): user = UserModel.find_by_username(user_data["username"]) if user and pbkdf2_sha256.verify(user_data["password"], user.password): access_token = create_access_token(identity=str(user.id), fresh=True) refresh_token = create_refresh_token(user.id) return {"access_token": access_token, "refresh_token": refresh_token}, 200 abort(401, message="Invalid credentials.") @blp.route("/logout") class UserLogout(MethodView): @jwt_required() def post(self): jti = get_jwt()["jti"] BLOCKLIST.add(jti) return {"message": "Successfully logged out"}, 200 @blp.route("/user/") class User(MethodView): """ This resource can be useful when testing our Flask app. We may not want to expose it to public users, but for the sake of demonstration in this course, it can be useful when we are manipulating data regarding the users. """ @blp.response(200, UserSchema) def get(self, user_id): user = UserModel.find_by_id(user_id) if not user: abort(404, message="User not found.") return user def delete(self, user_id): user = UserModel.find_by_id(user_id) if not user: abort(404, message="User not found.") user.delete_from_db() return {"message": "User deleted."}, 200 @blp.route("/refresh") class TokenRefresh(MethodView): @jwt_required(refresh=True) def post(self): current_user = get_jwt_identity() new_token = create_access_token(identity=current_user, fresh=False) return {"access_token": new_token}, 200 ================================================ FILE: project/using-flask-smorest/schemas.py ================================================ from marshmallow import Schema, fields class ItemSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True, dump_only=True) price = fields.Float(required=True) store_id = fields.Int(required=True, load_only=True) store = fields.Nested(lambda: StoreWitoutItemsSchema(), dump_only=True) tags = fields.List(fields.Nested(lambda: TagWithoutItemsSchema()), dump_only=True) class ItemWithoutStoreSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True, dump_only=True) price = fields.Float(required=True) tags = fields.List(fields.Nested(lambda: TagWithoutItemsSchema()), dump_only=True) class ItemWithoutTagsSchema(Schema): id = fields.Int(dump_only=True) name = fields.Str(required=True, dump_only=True) price = fields.Float(required=True) store_id = fields.Int(required=True, load_only=True) store = fields.Nested(lambda: StoreWitoutItemsSchema(), dump_only=True) class ItemUpdateSchema(Schema): price = fields.Float(required=True) class StoreSchema(Schema): id = fields.Int() name = fields.Str() items = fields.List(fields.Nested(ItemWithoutStoreSchema()), dump_only=True) class StoreWitoutItemsSchema(Schema): id = fields.Int() name = fields.Str() class TagSchema(Schema): id = fields.Int() name = fields.Str() items = fields.List(fields.Nested(ItemWithoutTagsSchema()), dump_only=True) class TagWithoutItemsSchema(Schema): id = fields.Int() name = fields.Str() class TagUpdateSchema(Schema): item_id = fields.Int() class TagAndItemSchema(Schema): message = fields.Str() item = fields.Nested(ItemSchema) tag = fields.Nested(TagSchema) class UserSchema(Schema): id = fields.Int() username = fields.Str() password = fields.Str(load_only=True)