├── .azdo └── pipelines │ └── azure-dev.yml ├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github └── workflows │ └── azure-dev.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── LICENSE ├── NOTICE.txt ├── README.md ├── assets ├── resources-with-apim.png ├── resources.png ├── urls.png └── web.png ├── azure.yaml ├── infra ├── main.tf ├── main.tfvars.json ├── modules │ ├── apim-api │ │ ├── apim-api-policy.xml │ │ ├── apim-api.tf │ │ ├── apim-api_output.tf │ │ └── apim-api_variables.tf │ ├── apim │ │ ├── apim.tf │ │ ├── apim_output.tf │ │ └── apim_variables.tf │ ├── applicationinsights │ │ ├── applicationinsights.tf │ │ ├── applicationinsights_output.tf │ │ └── applicationinsights_variables.tf │ ├── appservicenode │ │ ├── appservicenode.tf │ │ ├── appservicenode_output.tf │ │ └── appservicenode_variables.tf │ ├── appserviceplan │ │ ├── appserviceplan.tf │ │ ├── appserviceplan_output.tf │ │ └── appserviceplan_variables.tf │ ├── appservicepython │ │ ├── appservicepython.tf │ │ ├── appservicepython_output.tf │ │ └── appservicepython_variables.tf │ ├── cosmos │ │ ├── cosmos.tf │ │ ├── cosmos_output.tf │ │ └── cosmos_variables.tf │ ├── keyvault │ │ ├── keyvault.tf │ │ ├── keyvault_output.tf │ │ └── keyvault_variables.tf │ └── loganalytics │ │ ├── loganalytics.tf │ │ ├── loganalytics_output.tf │ │ └── loganalytics_variables.tf ├── output.tf ├── provider.tf └── variables.tf ├── openapi.yaml ├── src ├── api │ ├── .gitignore │ ├── .vscode │ │ ├── launch.json │ │ └── settings.json │ ├── Dockerfile │ ├── README.md │ ├── openapi.yaml │ ├── pyproject.toml │ ├── requirements-test.txt │ ├── requirements.txt │ ├── tests │ │ ├── conftest.py │ │ └── test_main.py │ └── todo │ │ ├── __init__.py │ │ ├── app.py │ │ ├── models.py │ │ └── routes.py └── web │ ├── .dockerignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── index.html │ ├── nginx │ └── nginx.conf │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ └── manifest.json │ ├── src │ ├── @types │ │ └── window.d.ts │ ├── App.css │ ├── App.tsx │ ├── actions │ │ ├── actionCreators.ts │ │ ├── common.ts │ │ ├── itemActions.ts │ │ └── listActions.ts │ ├── components │ │ ├── telemetry.tsx │ │ ├── telemetryContext.ts │ │ ├── telemetryWithAppInsights.tsx │ │ ├── todoContext.ts │ │ ├── todoItemDetailPane.tsx │ │ ├── todoItemListPane.tsx │ │ └── todoListMenu.tsx │ ├── config │ │ └── index.ts │ ├── index.css │ ├── index.tsx │ ├── layout │ │ ├── header.tsx │ │ ├── layout.tsx │ │ └── sidebar.tsx │ ├── models │ │ ├── applicationState.ts │ │ ├── index.ts │ │ ├── todoItem.ts │ │ └── todoList.ts │ ├── pages │ │ └── homePage.tsx │ ├── react-app-env.d.ts │ ├── reducers │ │ ├── index.ts │ │ ├── listsReducer.ts │ │ ├── selectedItemReducer.ts │ │ └── selectedListReducer.ts │ ├── reportWebVitals.ts │ ├── services │ │ ├── itemService.ts │ │ ├── listService.ts │ │ ├── restService.ts │ │ └── telemetryService.ts │ ├── setupTests.ts │ └── ux │ │ ├── styles.ts │ │ └── theme.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── web.config └── tests ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── playwright.config.ts └── todo.spec.ts /.azdo/pipelines/azure-dev.yml: -------------------------------------------------------------------------------- 1 | # Run when commits are pushed to mainline branch (main or master) 2 | # Set this to the mainline branch you are using 3 | trigger: 4 | - main 5 | - master 6 | 7 | # Azure Pipelines workflow to deploy to Azure using azd 8 | # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` 9 | # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd 10 | # See below for alternative task to install azd if you can't install above task in your organization 11 | 12 | pool: 13 | vmImage: ubuntu-latest 14 | 15 | steps: 16 | - task: setup-azd@1 17 | displayName: Install azd 18 | 19 | # If you can't install above task in your organization, you can comment it and uncomment below task to install azd 20 | # - task: Bash@3 21 | # displayName: Install azd 22 | # inputs: 23 | # targetType: 'inline' 24 | # script: | 25 | # curl -fsSL https://aka.ms/install-azd.sh | bash 26 | 27 | # azd delegate auth to az to use service connection with AzureCLI@2 28 | - pwsh: | 29 | azd config set auth.useAzCliAuth "true" 30 | displayName: Configure AZD to Use AZ CLI Authentication. 31 | 32 | - task: AzureCLI@2 33 | displayName: Provision Infrastructure 34 | inputs: 35 | azureSubscription: azconnection 36 | scriptType: bash 37 | scriptLocation: inlineScript 38 | inlineScript: | 39 | azd provision --no-prompt 40 | env: 41 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 42 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 43 | AZURE_LOCATION: $(AZURE_LOCATION) 44 | ARM_TENANT_ID: $(ARM_TENANT_ID) 45 | ARM_CLIENT_ID: $(ARM_CLIENT_ID) 46 | ARM_CLIENT_SECRET: $(ARM_CLIENT_SECRET) 47 | RS_RESOURCE_GROUP: $(RS_RESOURCE_GROUP) 48 | RS_STORAGE_ACCOUNT: $(RS_STORAGE_ACCOUNT) 49 | RS_CONTAINER_NAME: $(RS_CONTAINER_NAME) 50 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(secrets.AZD_INITIAL_ENVIRONMENT_CONFIG) 51 | 52 | - task: AzureCLI@2 53 | displayName: Deploy Application 54 | inputs: 55 | azureSubscription: azconnection 56 | scriptType: bash 57 | scriptLocation: inlineScript 58 | inlineScript: | 59 | azd deploy --no-prompt 60 | env: 61 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 62 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 63 | AZURE_LOCATION: $(AZURE_LOCATION) 64 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Azure Developer CLI", 3 | "image": "mcr.microsoft.com/devcontainers/python:3.10-bullseye", 4 | "features": { 5 | "ghcr.io/devcontainers/features/azure-cli:1": { 6 | }, 7 | "ghcr.io/devcontainers/features/docker-in-docker:2": { 8 | }, 9 | "ghcr.io/devcontainers/features/node:1": { 10 | "version": "18", 11 | "nodeGypDependencies": false 12 | }, 13 | "ghcr.io/devcontainers/features/terraform:1": { 14 | "version": "latest" 15 | }, 16 | "ghcr.io/azure/azure-dev/azd:latest": {} 17 | }, 18 | "customizations": { 19 | "vscode": { 20 | "extensions": [ 21 | "GitHub.vscode-github-actions", 22 | "hashicorp.terraform", 23 | "ms-azuretools.azure-dev", 24 | "ms-azuretools.vscode-azurefunctions", 25 | "ms-azuretools.vscode-docker", 26 | "ms-python.python", 27 | "ms-vscode.vscode-node-azure-pack" 28 | ] 29 | } 30 | }, 31 | "forwardPorts": [ 32 | 3000, 33 | 3100 34 | ], 35 | "postCreateCommand": "", 36 | "remoteUser": "vscode", 37 | "hostRequirements": { 38 | "memory": "8gb" 39 | } 40 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.github/workflows/azure-dev.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | # Run when commits are pushed to mainline branch (main or master) 5 | # Set this to the mainline branch you are using 6 | branches: 7 | - main 8 | - master 9 | 10 | # GitHub Actions workflow to deploy to Azure using azd 11 | # To configure required secrets for connecting to Azure, simply run `azd pipeline config --auth-type client-credentials` 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install azd 21 | uses: Azure/setup-azd@v2 22 | 23 | - name: Install Nodejs 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 18 27 | 28 | - name: Login az 29 | uses: azure/login@v1 30 | with: 31 | creds: ${{ secrets.AZURE_CREDENTIALS }} 32 | 33 | - name: Set az account 34 | uses: azure/CLI@v1 35 | with: 36 | inlineScript: | 37 | az account set --subscription ${{vars.AZURE_SUBSCRIPTION_ID}} 38 | 39 | - name: Log in with Azure 40 | run: | 41 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 42 | Write-Host "::add-mask::$($info.clientSecret)" 43 | 44 | azd auth login ` 45 | --client-id "$($info.clientId)" ` 46 | --client-secret "$($info.clientSecret)" ` 47 | --tenant-id "$($info.tenantId)" 48 | shell: pwsh 49 | env: 50 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 51 | 52 | - name: Provision Infrastructure 53 | run: azd provision --no-prompt 54 | env: 55 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 56 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 57 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 58 | ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }} 59 | ARM_CLIENT_ID: ${{ vars.ARM_CLIENT_ID }} 60 | ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }} 61 | RS_RESOURCE_GROUP: ${{ vars.RS_RESOURCE_GROUP }} 62 | RS_STORAGE_ACCOUNT: ${{ vars.RS_STORAGE_ACCOUNT }} 63 | RS_CONTAINER_NAME: ${{ vars.RS_CONTAINER_NAME }} 64 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 65 | 66 | - name: Deploy Application 67 | run: azd deploy --no-prompt 68 | env: 69 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 70 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 71 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .azure -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.azure-dev" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Web", 9 | "request": "launch", 10 | "type": "msedge", 11 | "webRoot": "${workspaceFolder}/src/web/src", 12 | "url": "http://localhost:3000", 13 | "sourceMapPathOverrides": { 14 | "webpack:///src/*": "${webRoot}/*" 15 | }, 16 | }, 17 | 18 | { 19 | "name": "Debug API", 20 | "type": "python", 21 | "request": "launch", 22 | "module": "uvicorn", 23 | "cwd": "${workspaceFolder}/src/api", 24 | "args": [ 25 | "todo.app:app", 26 | "--port", "3100", 27 | "--reload" 28 | ], 29 | "justMyCode": true, 30 | "python": "${workspaceFolder}/src/api/api_env/bin/python3", 31 | "envFile": "${input:dotEnvFilePath}", 32 | "windows": { 33 | "python": "${workspaceFolder}/src/api/api_env/scripts/python.exe" 34 | }, 35 | "preLaunchTask": "Restore API", 36 | "env": { 37 | "API_ENVIRONMENT":"develop" 38 | } 39 | } 40 | ], 41 | 42 | "inputs": [ 43 | { 44 | "id": "dotEnvFilePath", 45 | "type": "command", 46 | "command": "azure-dev.commands.getDotEnvFilePath" 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start Web", 6 | "type": "dotenv", 7 | "targetTasks": [ 8 | "Restore Web", 9 | "Web npm start" 10 | ], 11 | "file": "${input:dotEnvFilePath}" 12 | }, 13 | { 14 | "label": "Restore Web", 15 | "type": "shell", 16 | "command": "azd restore web", 17 | "presentation": { 18 | "reveal": "silent" 19 | }, 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Web npm start", 24 | "detail": "Helper task--use 'Start Web' task to ensure environment is set up correctly", 25 | "type": "shell", 26 | "command": "npx -y cross-env VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" npm run dev", 27 | "options": { 28 | "cwd": "${workspaceFolder}/src/web/", 29 | "env": { 30 | "VITE_API_BASE_URL": "http://localhost:3100", 31 | "BROWSER": "none" 32 | } 33 | }, 34 | "presentation": { 35 | "panel": "dedicated", 36 | }, 37 | "problemMatcher": [] 38 | }, 39 | 40 | { 41 | "label": "Start API", 42 | "type": "dotenv", 43 | "targetTasks": [ 44 | "Restore API", 45 | "API uvicorn launch" 46 | ], 47 | "file": "${input:dotEnvFilePath}" 48 | }, 49 | { 50 | "label": "Restore API", 51 | "type": "shell", 52 | "command": "azd restore api", 53 | "presentation": { 54 | "reveal": "silent" 55 | }, 56 | "problemMatcher": [] 57 | }, 58 | { 59 | "label": "API uvicorn launch", 60 | "detail": "Helper task--use 'Start API' task to ensure environment is set up correctly", 61 | "type": "shell", 62 | "command": "${workspaceFolder}/src/api/api_env/bin/uvicorn todo.app:app --port 3100 --reload", 63 | "options": { 64 | "cwd": "${workspaceFolder}/src/api/", 65 | "env": { 66 | "API_ENVIRONMENT": "develop" 67 | } 68 | }, 69 | "windows": { 70 | "command": "${workspaceFolder}/src/api/api_env/scripts/uvicorn todo.app:app --port 3100 --reload" 71 | }, 72 | "presentation": { 73 | "panel": "dedicated", 74 | }, 75 | "problemMatcher": [] 76 | }, 77 | 78 | { 79 | "label": "Start API and Web", 80 | "dependsOn":[ 81 | "Start API", 82 | "Start Web" 83 | ], 84 | "problemMatcher": [] 85 | } 86 | ], 87 | 88 | "inputs": [ 89 | { 90 | "id": "dotEnvFilePath", 91 | "type": "command", 92 | "command": "azure-dev.commands.getDotEnvFilePath" 93 | } 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022 (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - python 6 | - terraform 7 | - typescript 8 | - html 9 | products: 10 | - azure 11 | - azure-cosmos-db 12 | - azure-app-service 13 | - azure-monitor 14 | - azure-pipelines 15 | urlFragment: todo-python-mongo-terraform 16 | name: React Web App with Python API and MongoDB (Terraform) on Azure 17 | description: A complete ToDo app with Python FastAPI and Azure Cosmos API for MongoDB for storage. Uses Azure Developer CLI (azd) to build, deploy, and monitor 18 | --- 19 | 20 | 21 | # React Web App with Python API and MongoDB (Terraform) on Azure 22 | 23 | [![Open in GitHub Codespaces](https://img.shields.io/static/v1?style=for-the-badge&label=GitHub+Codespaces&message=Open&color=brightgreen&logo=github)](https://codespaces.new/azure-samples/todo-python-mongo-terraform) 24 | [![Open in Dev Container](https://img.shields.io/static/v1?style=for-the-badge&label=Dev+Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/todo-python-mongo-terraform) 25 | 26 | A blueprint for getting a React web app with Python (FastAPI) API and a MongoDB database running on Azure. The blueprint includes sample application code (a ToDo web app) which can be removed and replaced with your own application code. Add your own source code and leverage the Infrastructure as Code assets (written in Terraform) to get up and running quickly. 27 | 28 | Let's jump in and get this up and running in Azure. When you are finished, you will have a fully functional web app deployed to the cloud. In later steps, you'll see how to setup a pipeline and monitor the application. 29 | 30 | !["Screenshot of deployed ToDo app"](assets/web.png) 31 | 32 | Screenshot of the deployed ToDo app 33 | 34 | ### Prerequisites 35 | > This template will create infrastructure and deploy code to Azure. If you don't have an Azure Subscription, you can sign up for a [free account here](https://azure.microsoft.com/free/). Make sure you have contributor role to the Azure subscription. 36 | 37 | 38 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally. 39 | 40 | - [Azure Developer CLI](https://aka.ms/azd-install) 41 | - [Python (3.8+)](https://www.python.org/downloads/) - for the API backend 42 | - [Node.js with npm (18.17.1+)](https://nodejs.org/) - for the Web frontend 43 | - [Terraform CLI](https://aka.ms/azure-dev/terraform-install) 44 | - Requires the [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) 45 | 46 | ### Quickstart 47 | To learn how to get started with any template, follow the steps in [this quickstart](https://learn.microsoft.com/azure/developer/azure-developer-cli/get-started?tabs=localinstall&pivots=programming-language-python) with this template(`Azure-Samples/todo-python-mongo-terraform`). 48 | 49 | This quickstart will show you how to authenticate on Azure, initialize using a template, provision infrastructure and deploy code on Azure via the following commands: 50 | 51 | ```bash 52 | # Log in to azd. Only required once per-install. 53 | azd auth login 54 | 55 | # First-time project setup. Initialize a project in the current directory, using this template. 56 | azd init --template Azure-Samples/todo-python-mongo-terraform 57 | 58 | # Provision and deploy to Azure 59 | azd up 60 | ``` 61 | 62 | ### Application Architecture 63 | 64 | This application utilizes the following Azure resources: 65 | 66 | - [**Azure App Services**](https://docs.microsoft.com/azure/app-service/) to host the Web frontend and API backend 67 | - [**Azure Cosmos DB API for MongoDB**](https://docs.microsoft.com/azure/cosmos-db/mongodb/mongodb-introduction) for storage 68 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 69 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 70 | 71 | Here's a high level architecture diagram that illustrates these components. Notice that these are all contained within a single [resource group](https://docs.microsoft.com/azure/azure-resource-manager/management/manage-resource-groups-portal), that will be created for you when you create the resources. 72 | 73 | !["Application architecture diagram"](assets/resources.png) 74 | 75 | > This template provisions resources to an Azure subscription that you will select upon provisioning them. Please refer to the [Pricing calculator for Microsoft Azure](https://azure.microsoft.com/pricing/calculator/) and, if needed, update the included Azure resource definitions found in `infra/main.bicep` to suit your needs. 76 | 77 | ### Application Code 78 | 79 | This template is structured to follow the [Azure Developer CLI](https://aka.ms/azure-dev/overview). You can learn more about `azd` architecture in [the official documentation](https://learn.microsoft.com/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-create#understand-the-azd-architecture). 80 | 81 | ### Next Steps 82 | 83 | At this point, you have a complete application deployed on Azure. But there is much more that the Azure Developer CLI can do. These next steps will introduce you to additional commands that will make creating applications on Azure much easier. Using the Azure Developer CLI, you can setup your pipelines, monitor your application, test and debug locally. 84 | 85 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 86 | 87 | - [`azd pipeline config`](https://learn.microsoft.com/azure/developer/azure-developer-cli/configure-devops-pipeline?tabs=GitHub) - to configure a CI/CD pipeline (using GitHub Actions or Azure DevOps) to deploy your application whenever code is pushed to the main branch. 88 | 89 | - [`azd monitor`](https://learn.microsoft.com/azure/developer/azure-developer-cli/monitor-your-app) - to monitor the application and quickly navigate to the various Application Insights dashboards (e.g. overview, live metrics, logs) 90 | 91 | - [Run and Debug Locally](https://learn.microsoft.com/azure/developer/azure-developer-cli/debug?pivots=ide-vs-code) - using Visual Studio Code and the Azure Developer CLI extension 92 | 93 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 94 | 95 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 96 | 97 | ### Additional `azd` commands 98 | 99 | The Azure Developer CLI includes many other commands to help with your Azure development experience. You can view these commands at the terminal by running `azd help`. You can also view the full list of commands on our [Azure Developer CLI command](https://aka.ms/azure-dev/ref) page. 100 | 101 | ## Security 102 | 103 | ### Roles 104 | 105 | This template creates a [managed identity](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview) for your app inside your Azure Active Directory tenant, and it is used to authenticate your app with Azure and other services that support Azure AD authentication like Key Vault via access policies. You will see principalId referenced in the infrastructure as code files, that refers to the id of the currently logged in Azure Developer CLI user, which will be granted access policies and permissions to run the application locally. To view your managed identity in the Azure Portal, follow these [steps](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/how-to-view-managed-identity-service-principal-portal). 106 | 107 | ### Key Vault 108 | 109 | This template uses [Azure Key Vault](https://docs.microsoft.com/azure/key-vault/general/overview) to securely store your Cosmos DB connection string for the provisioned Cosmos DB account. Key Vault is a cloud service for securely storing and accessing secrets (API keys, passwords, certificates, cryptographic keys) and makes it simple to give other Azure services access to them. As you continue developing your solution, you may add as many secrets to your Key Vault as you require. 110 | 111 | ## Reporting Issues and Feedback 112 | 113 | If you have any feature requests, issues, or areas for improvement, please [file an issue](https://aka.ms/azure-dev/issues). To keep up-to-date, ask questions, or share suggestions, join our [GitHub Discussions](https://aka.ms/azure-dev/discussions). You may also contact us via AzDevTeam@microsoft.com. 114 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/e8c10a1d31e56ced02d9acf630881634da04f265/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/e8c10a1d31e56ced02d9acf630881634da04f265/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/e8c10a1d31e56ced02d9acf630881634da04f265/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/e8c10a1d31e56ced02d9acf630881634da04f265/assets/web.png -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/alpha/azure.yaml.json 2 | 3 | name: todo-python-mongo-terraform 4 | metadata: 5 | template: todo-python-mongo-terraform@0.0.1-beta 6 | workflows: 7 | up: 8 | steps: 9 | - azd: provision 10 | - azd: deploy --all 11 | infra: 12 | provider: terraform 13 | services: 14 | web: 15 | project: ./src/web 16 | dist: dist 17 | language: js 18 | host: appservice 19 | hooks: 20 | # Creates a temporary `.env.local` file for the build command. Vite will automatically use it during build. 21 | # The expected/required values are mapped to the infrastructure outputs. 22 | # .env.local is ignored by git, so it will not be committed if, for any reason, if deployment fails. 23 | # see: https://vitejs.dev/guide/env-and-mode 24 | # Note: Notice that dotenv must be a project dependency for this to work. See package.json. 25 | prepackage: 26 | windows: 27 | shell: pwsh 28 | run: 'echo "VITE_API_BASE_URL=""$env:API_BASE_URL""" > .env.local ; echo "VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=""$env:APPLICATIONINSIGHTS_CONNECTION_STRING""" >> .env.local' 29 | posix: 30 | shell: sh 31 | run: 'echo VITE_API_BASE_URL=\"$API_BASE_URL\" > .env.local && echo VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" >> .env.local' 32 | postdeploy: 33 | windows: 34 | shell: pwsh 35 | run: 'rm .env.local' 36 | posix: 37 | shell: sh 38 | run: 'rm .env.local' 39 | api: 40 | project: ./src/api 41 | language: py 42 | host: appservice 43 | -------------------------------------------------------------------------------- /infra/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | tags = { azd-env-name : var.environment_name } 3 | sha = base64encode(sha256("${var.environment_name}${var.location}${data.azurerm_client_config.current.subscription_id}")) 4 | resource_token = substr(replace(lower(local.sha), "[^A-Za-z0-9_]", ""), 0, 13) 5 | api_command_line = "gunicorn --workers 4 --threads 2 --timeout 60 --access-logfile \"-\" --error-logfile \"-\" --bind=0.0.0.0:8000 -k uvicorn.workers.UvicornWorker todo.app:app" 6 | cosmos_connection_string_key = "AZURE-COSMOS-CONNECTION-STRING" 7 | } 8 | # ------------------------------------------------------------------------------------------------------ 9 | # Deploy resource Group 10 | # ------------------------------------------------------------------------------------------------------ 11 | resource "azurecaf_name" "rg_name" { 12 | name = var.environment_name 13 | resource_type = "azurerm_resource_group" 14 | random_length = 0 15 | clean_input = true 16 | } 17 | 18 | resource "azurerm_resource_group" "rg" { 19 | name = azurecaf_name.rg_name.result 20 | location = var.location 21 | 22 | tags = local.tags 23 | } 24 | 25 | # ------------------------------------------------------------------------------------------------------ 26 | # Deploy application insights 27 | # ------------------------------------------------------------------------------------------------------ 28 | module "applicationinsights" { 29 | source = "./modules/applicationinsights" 30 | location = var.location 31 | rg_name = azurerm_resource_group.rg.name 32 | environment_name = var.environment_name 33 | workspace_id = module.loganalytics.LOGANALYTICS_WORKSPACE_ID 34 | tags = azurerm_resource_group.rg.tags 35 | resource_token = local.resource_token 36 | } 37 | 38 | # ------------------------------------------------------------------------------------------------------ 39 | # Deploy log analytics 40 | # ------------------------------------------------------------------------------------------------------ 41 | module "loganalytics" { 42 | source = "./modules/loganalytics" 43 | location = var.location 44 | rg_name = azurerm_resource_group.rg.name 45 | tags = azurerm_resource_group.rg.tags 46 | resource_token = local.resource_token 47 | } 48 | 49 | # ------------------------------------------------------------------------------------------------------ 50 | # Deploy key vault 51 | # ------------------------------------------------------------------------------------------------------ 52 | module "keyvault" { 53 | source = "./modules/keyvault" 54 | location = var.location 55 | principal_id = var.principal_id 56 | rg_name = azurerm_resource_group.rg.name 57 | tags = azurerm_resource_group.rg.tags 58 | resource_token = local.resource_token 59 | access_policy_object_ids = [module.api.IDENTITY_PRINCIPAL_ID] 60 | secrets = [ 61 | { 62 | name = local.cosmos_connection_string_key 63 | value = module.cosmos.AZURE_COSMOS_CONNECTION_STRING 64 | } 65 | ] 66 | } 67 | 68 | # ------------------------------------------------------------------------------------------------------ 69 | # Deploy cosmos 70 | # ------------------------------------------------------------------------------------------------------ 71 | module "cosmos" { 72 | source = "./modules/cosmos" 73 | location = var.location 74 | rg_name = azurerm_resource_group.rg.name 75 | tags = azurerm_resource_group.rg.tags 76 | resource_token = local.resource_token 77 | } 78 | 79 | # ------------------------------------------------------------------------------------------------------ 80 | # Deploy app service plan 81 | # ------------------------------------------------------------------------------------------------------ 82 | module "appserviceplan" { 83 | source = "./modules/appserviceplan" 84 | location = var.location 85 | rg_name = azurerm_resource_group.rg.name 86 | tags = azurerm_resource_group.rg.tags 87 | resource_token = local.resource_token 88 | sku_name = "B3" 89 | } 90 | 91 | # ------------------------------------------------------------------------------------------------------ 92 | # Deploy app service web app 93 | # ------------------------------------------------------------------------------------------------------ 94 | module "web" { 95 | source = "./modules/appservicenode" 96 | location = var.location 97 | rg_name = azurerm_resource_group.rg.name 98 | resource_token = local.resource_token 99 | 100 | tags = merge(local.tags, { azd-service-name : "web" }) 101 | service_name = "web" 102 | appservice_plan_id = module.appserviceplan.APPSERVICE_PLAN_ID 103 | 104 | app_settings = { 105 | "SCM_DO_BUILD_DURING_DEPLOYMENT" = "false" 106 | } 107 | 108 | app_command_line = "pm2 serve /home/site/wwwroot --no-daemon --spa" 109 | } 110 | 111 | # ------------------------------------------------------------------------------------------------------ 112 | # Deploy app service api 113 | # ------------------------------------------------------------------------------------------------------ 114 | module "api" { 115 | source = "./modules/appservicepython" 116 | location = var.location 117 | rg_name = azurerm_resource_group.rg.name 118 | resource_token = local.resource_token 119 | 120 | tags = merge(local.tags, { "azd-service-name" : "api" }) 121 | service_name = "api" 122 | appservice_plan_id = module.appserviceplan.APPSERVICE_PLAN_ID 123 | app_settings = { 124 | "AZURE_COSMOS_CONNECTION_STRING_KEY" = local.cosmos_connection_string_key 125 | "AZURE_COSMOS_DATABASE_NAME" = module.cosmos.AZURE_COSMOS_DATABASE_NAME 126 | "SCM_DO_BUILD_DURING_DEPLOYMENT" = "true" 127 | "AZURE_KEY_VAULT_ENDPOINT" = module.keyvault.AZURE_KEY_VAULT_ENDPOINT 128 | "APPLICATIONINSIGHTS_CONNECTION_STRING" = module.applicationinsights.APPLICATIONINSIGHTS_CONNECTION_STRING 129 | } 130 | 131 | app_command_line = local.api_command_line 132 | identity = [{ 133 | type = "SystemAssigned" 134 | }] 135 | } 136 | 137 | # Workaround: set API_ALLOW_ORIGINS to the web app URI 138 | resource "null_resource" "api_set_allow_origins" { 139 | triggers = { 140 | web_uri = module.web.URI 141 | } 142 | 143 | provisioner "local-exec" { 144 | command = "az webapp config appsettings set --resource-group ${azurerm_resource_group.rg.name} --name ${module.api.APPSERVICE_NAME} --settings API_ALLOW_ORIGINS=${module.web.URI}" 145 | } 146 | } 147 | 148 | # ------------------------------------------------------------------------------------------------------ 149 | # Deploy app service apim 150 | # ------------------------------------------------------------------------------------------------------ 151 | module "apim" { 152 | count = var.useAPIM ? 1 : 0 153 | source = "./modules/apim" 154 | name = "apim-${local.resource_token}" 155 | location = var.location 156 | rg_name = azurerm_resource_group.rg.name 157 | tags = merge(local.tags, { "azd-service-name" : var.environment_name }) 158 | application_insights_name = module.applicationinsights.APPLICATIONINSIGHTS_NAME 159 | sku = var.apimSKU 160 | } 161 | 162 | # ------------------------------------------------------------------------------------------------------ 163 | # Deploy app service apim-api 164 | # ------------------------------------------------------------------------------------------------------ 165 | module "apimApi" { 166 | count = var.useAPIM ? 1 : 0 167 | source = "./modules/apim-api" 168 | name = module.apim[0].APIM_SERVICE_NAME 169 | rg_name = azurerm_resource_group.rg.name 170 | web_front_end_url = module.web.URI 171 | api_management_logger_id = module.apim[0].API_MANAGEMENT_LOGGER_ID 172 | api_name = "todo-api" 173 | api_display_name = "Simple Todo API" 174 | api_path = "todo" 175 | api_backend_url = module.api.URI 176 | } 177 | -------------------------------------------------------------------------------- /infra/main.tfvars.json: -------------------------------------------------------------------------------- 1 | { 2 | "location": "${AZURE_LOCATION}", 3 | "environment_name": "${AZURE_ENV_NAME}", 4 | "principal_id": "${AZURE_PRINCIPAL_ID}", 5 | "useAPIM" : "${USE_APIM=false}", 6 | "apimSKU": "${APIM_SKU=Consumption}" 7 | } 8 | -------------------------------------------------------------------------------- /infra/modules/apim-api/apim-api-policy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {origin} 9 | 10 | 11 | PUT 12 | GET 13 | POST 14 | DELETE 15 | PATCH 16 | 17 | 18 |
*
19 |
20 | 21 |
*
22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | Call to the @(context.Api.Name) 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Failed to process the @(context.Api.Name) 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | An unexpected error has occurred. 90 | 91 | 92 |
93 | -------------------------------------------------------------------------------- /infra/modules/apim-api/apim-api.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | 14 | data "azurerm_api_management" "apim" { 15 | name = var.name 16 | resource_group_name = var.rg_name 17 | } 18 | 19 | # ------------------------------------------------------------------------------------------------------ 20 | # Deploy apim-api service 21 | # ------------------------------------------------------------------------------------------------------ 22 | resource "azurerm_api_management_api" "api" { 23 | name = var.api_name 24 | resource_group_name = var.rg_name 25 | api_management_name = data.azurerm_api_management.apim.name 26 | revision = "1" 27 | display_name = var.api_display_name 28 | path = var.api_path 29 | protocols = ["https"] 30 | service_url = var.api_backend_url 31 | subscription_required = false 32 | 33 | import { 34 | content_format = "openapi" 35 | content_value = file("${path.module}/../../../src/api/openapi.yaml") 36 | } 37 | } 38 | 39 | resource "azurerm_api_management_api_policy" "policies" { 40 | api_name = azurerm_api_management_api.api.name 41 | api_management_name = azurerm_api_management_api.api.api_management_name 42 | resource_group_name = var.rg_name 43 | 44 | xml_content = replace(file("${path.module}/apim-api-policy.xml"), "{origin}", var.web_front_end_url) 45 | } 46 | 47 | resource "azurerm_api_management_api_diagnostic" "diagnostics" { 48 | identifier = "applicationinsights" 49 | resource_group_name = var.rg_name 50 | api_management_name = azurerm_api_management_api.api.api_management_name 51 | api_name = azurerm_api_management_api.api.name 52 | api_management_logger_id = var.api_management_logger_id 53 | 54 | sampling_percentage = 100.0 55 | always_log_errors = true 56 | log_client_ip = true 57 | verbosity = "verbose" 58 | http_correlation_protocol = "W3C" 59 | 60 | frontend_request { 61 | body_bytes = 1024 62 | headers_to_log = [ 63 | "content-type", 64 | "accept", 65 | "origin", 66 | ] 67 | } 68 | 69 | frontend_response { 70 | body_bytes = 1024 71 | headers_to_log = [ 72 | "content-type", 73 | "content-length", 74 | "origin", 75 | ] 76 | } 77 | 78 | backend_request { 79 | body_bytes = 1024 80 | headers_to_log = [ 81 | "content-type", 82 | "accept", 83 | "origin", 84 | ] 85 | } 86 | 87 | backend_response { 88 | body_bytes = 1024 89 | headers_to_log = [ 90 | "content-type", 91 | "content-length", 92 | "origin", 93 | ] 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /infra/modules/apim-api/apim-api_output.tf: -------------------------------------------------------------------------------- 1 | output "SERVICE_API_URI" { 2 | value = "${data.azurerm_api_management.apim.gateway_url}/${var.api_path}" 3 | } 4 | -------------------------------------------------------------------------------- /infra/modules/apim-api/apim-api_variables.tf: -------------------------------------------------------------------------------- 1 | variable "name" { 2 | type = string 3 | } 4 | 5 | variable "rg_name" { 6 | description = "The name of the resource group to deploy resources into" 7 | type = string 8 | } 9 | 10 | variable "api_management_logger_id" { 11 | description = "The name of the resource application insights" 12 | type = string 13 | } 14 | 15 | variable "web_front_end_url" { 16 | description = "The url of the web" 17 | type = string 18 | } 19 | 20 | variable "api_backend_url" { 21 | description = "Absolute URL of the backend service implementing this API." 22 | type = string 23 | } 24 | 25 | variable "api_name" { 26 | description = "Resource name to uniquely identify this API within the API Management service instance" 27 | type = string 28 | } 29 | 30 | variable "api_display_name" { 31 | 32 | description = "The Display Name of the API" 33 | type = string 34 | } 35 | 36 | variable "api_path" { 37 | description = "Relative URL uniquely identifying this API and all of its resource paths within the API Management service instance. It is appended to the API endpoint base URL specified during the service instance creation to form a public URL for this API." 38 | type = string 39 | } 40 | -------------------------------------------------------------------------------- /infra/modules/apim/apim.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | 14 | data "azurerm_application_insights" "appinsights"{ 15 | name = var.application_insights_name 16 | resource_group_name = var.rg_name 17 | } 18 | # ------------------------------------------------------------------------------------------------------ 19 | # Deploy api management service 20 | # ------------------------------------------------------------------------------------------------------ 21 | 22 | # Create a new APIM instance 23 | resource "azurerm_api_management" "apim" { 24 | name = var.name 25 | location = var.location 26 | resource_group_name = var.rg_name 27 | publisher_name = var.publisher_name 28 | publisher_email = var.publisher_email 29 | tags = var.tags 30 | sku_name = "${var.sku}_${(var.sku == "Consumption") ? 0 : ((var.sku == "Developer") ? 1 : var.skuCount)}" 31 | identity { 32 | type = "SystemAssigned" 33 | } 34 | } 35 | 36 | # Create Logger 37 | resource "azurerm_api_management_logger" "logger" { 38 | name = "app-insights-logger" 39 | api_management_name = azurerm_api_management.apim.name 40 | resource_group_name = var.rg_name 41 | 42 | application_insights { 43 | instrumentation_key = data.azurerm_application_insights.appinsights.instrumentation_key 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /infra/modules/apim/apim_output.tf: -------------------------------------------------------------------------------- 1 | output "APIM_SERVICE_NAME" { 2 | value = azurerm_api_management.apim.name 3 | } 4 | 5 | output "API_MANAGEMENT_LOGGER_ID" { 6 | value = azurerm_api_management_logger.logger.id 7 | } 8 | -------------------------------------------------------------------------------- /infra/modules/apim/apim_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "tags" { 12 | description = "A list of tags used for deployed services." 13 | type = map(string) 14 | } 15 | 16 | variable "sku" { 17 | description = "The pricing tier of this API Management service." 18 | type = string 19 | default = "Consumption" 20 | } 21 | 22 | variable "application_insights_name" { 23 | description = "Azure Application Insights Name." 24 | type = string 25 | } 26 | 27 | variable "skuCount" { 28 | description = "The instance size of this API Management service. @allowed([ 0, 1, 2 ])" 29 | type = string 30 | default = "0" 31 | } 32 | 33 | variable "name" { 34 | type = string 35 | } 36 | 37 | variable "publisher_email" { 38 | description = "The email address of the owner of the service." 39 | type = string 40 | default = "noreply@microsoft.com" 41 | } 42 | 43 | variable "publisher_name" { 44 | description = "The name of the owner of the service" 45 | type = string 46 | default = "n/a" 47 | } 48 | -------------------------------------------------------------------------------- /infra/modules/applicationinsights/applicationinsights_output.tf: -------------------------------------------------------------------------------- 1 | output "APPLICATIONINSIGHTS_CONNECTION_STRING" { 2 | value = azurerm_application_insights.applicationinsights.connection_string 3 | sensitive = true 4 | } 5 | 6 | output "APPLICATIONINSIGHTS_NAME" { 7 | value = azurerm_application_insights.applicationinsights.name 8 | sensitive = false 9 | } 10 | 11 | output "APPLICATIONINSIGHTS_INSTRUMENTATION_KEY" { 12 | value = azurerm_application_insights.applicationinsights.instrumentation_key 13 | sensitive = true 14 | } 15 | -------------------------------------------------------------------------------- /infra/modules/applicationinsights/applicationinsights_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "environment_name" { 12 | description = "The name of the environment to be deployed" 13 | type = string 14 | } 15 | 16 | variable "workspace_id" { 17 | description = "The name of the Azure log analytics workspace" 18 | type = string 19 | } 20 | 21 | variable "tags" { 22 | description = "A list of tags used for deployed services." 23 | type = map(string) 24 | } 25 | 26 | variable "resource_token" { 27 | description = "A suffix string to centrally mitigate resource name collisions." 28 | type = string 29 | } -------------------------------------------------------------------------------- /infra/modules/appservicenode/appservicenode.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | # ------------------------------------------------------------------------------------------------------ 14 | # Deploy app service web app 15 | # ------------------------------------------------------------------------------------------------------ 16 | resource "azurecaf_name" "web_name" { 17 | name = "${var.service_name}-${var.resource_token}" 18 | resource_type = "azurerm_app_service" 19 | random_length = 0 20 | clean_input = true 21 | } 22 | 23 | resource "azurerm_linux_web_app" "web" { 24 | name = azurecaf_name.web_name.result 25 | location = var.location 26 | resource_group_name = var.rg_name 27 | service_plan_id = var.appservice_plan_id 28 | https_only = true 29 | tags = var.tags 30 | 31 | site_config { 32 | always_on = var.always_on 33 | use_32_bit_worker = var.use_32_bit_worker 34 | ftps_state = "FtpsOnly" 35 | app_command_line = var.app_command_line 36 | application_stack { 37 | node_version = var.node_version 38 | } 39 | health_check_path = var.health_check_path 40 | } 41 | 42 | app_settings = var.app_settings 43 | 44 | dynamic "identity" { 45 | for_each = { for k, v in var.identity : k => v if var.identity != [] } 46 | content { 47 | type = identity.value["type"] 48 | } 49 | } 50 | 51 | logs { 52 | application_logs { 53 | file_system_level = "Verbose" 54 | } 55 | detailed_error_messages = true 56 | failed_request_tracing = true 57 | http_logs { 58 | file_system { 59 | retention_in_days = 1 60 | retention_in_mb = 35 61 | } 62 | } 63 | } 64 | } 65 | 66 | # This is a temporary solution until the azurerm provider supports the basicPublishingCredentialsPolicies resource type 67 | resource "null_resource" "webapp_basic_auth_disable" { 68 | triggers = { 69 | account = azurerm_linux_web_app.web.name 70 | } 71 | 72 | provisioner "local-exec" { 73 | command = "az resource update --resource-group ${var.rg_name} --name ftp --namespace Microsoft.Web --resource-type basicPublishingCredentialsPolicies --parent sites/${azurerm_linux_web_app.web.name} --set properties.allow=false && az resource update --resource-group ${var.rg_name} --name scm --namespace Microsoft.Web --resource-type basicPublishingCredentialsPolicies --parent sites/${azurerm_linux_web_app.web.name} --set properties.allow=false" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /infra/modules/appservicenode/appservicenode_output.tf: -------------------------------------------------------------------------------- 1 | output "URI" { 2 | value = "https://${azurerm_linux_web_app.web.default_hostname}" 3 | } 4 | 5 | output "IDENTITY_PRINCIPAL_ID" { 6 | value = length(azurerm_linux_web_app.web.identity) == 0 ? "" : azurerm_linux_web_app.web.identity.0.principal_id 7 | sensitive = true 8 | } 9 | 10 | output "APPSERVICE_NAME" { 11 | value = azurerm_linux_web_app.web.name 12 | } 13 | -------------------------------------------------------------------------------- /infra/modules/appservicenode/appservicenode_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "appservice_plan_id" { 12 | description = "The id of the appservice plan to use." 13 | type = string 14 | } 15 | 16 | variable "service_name" { 17 | description = "A name to reflect the type of the app service e.g: web, api." 18 | type = string 19 | } 20 | 21 | variable "app_settings" { 22 | description = "A list of app settings pairs to be assigned to the app service" 23 | type = map(string) 24 | } 25 | 26 | variable "identity" { 27 | description = "A list of application identity" 28 | type = list(any) 29 | default = [] 30 | } 31 | 32 | variable "app_command_line" { 33 | description = "The cmd line to configure the app to run." 34 | type = string 35 | } 36 | 37 | variable "tags" { 38 | description = "A list of tags used for deployed services." 39 | type = map(string) 40 | } 41 | 42 | variable "resource_token" { 43 | description = "A suffix string to centrally mitigate resource name collisions." 44 | type = string 45 | } 46 | 47 | variable "node_version" { 48 | description = "the application stack node version to set for the app service." 49 | type = string 50 | default = "20-lts" 51 | } 52 | 53 | variable "always_on" { 54 | description = "The always on setting for the app service." 55 | type = bool 56 | default = true 57 | } 58 | 59 | variable "use_32_bit_worker" { 60 | description = "The use 32 bit worker setting for the app service." 61 | type = bool 62 | default = false 63 | } 64 | 65 | variable "health_check_path" { 66 | description = "The path to the health check endpoint" 67 | type = string 68 | default = "" 69 | } 70 | -------------------------------------------------------------------------------- /infra/modules/appserviceplan/appserviceplan.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | # ------------------------------------------------------------------------------------------------------ 14 | # Deploy app service plan 15 | # ------------------------------------------------------------------------------------------------------ 16 | resource "azurecaf_name" "plan_name" { 17 | name = var.resource_token 18 | resource_type = "azurerm_app_service_plan" 19 | random_length = 0 20 | clean_input = true 21 | } 22 | 23 | resource "azurerm_service_plan" "plan" { 24 | name = azurecaf_name.plan_name.result 25 | location = var.location 26 | resource_group_name = var.rg_name 27 | os_type = var.os_type 28 | sku_name = var.sku_name 29 | 30 | tags = var.tags 31 | } 32 | -------------------------------------------------------------------------------- /infra/modules/appserviceplan/appserviceplan_output.tf: -------------------------------------------------------------------------------- 1 | output "APPSERVICE_PLAN_ID" { 2 | value = azurerm_service_plan.plan.id 3 | sensitive = true 4 | } -------------------------------------------------------------------------------- /infra/modules/appserviceplan/appserviceplan_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "tags" { 12 | description = "A list of tags used for deployed services." 13 | type = map(string) 14 | } 15 | 16 | variable "resource_token" { 17 | description = "A suffix string to centrally mitigate resource name collisions." 18 | type = string 19 | } 20 | 21 | variable "sku_name" { 22 | description = "The SKU for the plan." 23 | type = string 24 | default = "B1" 25 | } 26 | 27 | variable "os_type" { 28 | description = "The O/S type for the App Services to be hosted in this plan." 29 | type = string 30 | default = "Linux" 31 | } -------------------------------------------------------------------------------- /infra/modules/appservicepython/appservicepython.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | # ------------------------------------------------------------------------------------------------------ 14 | # Deploy app service web app 15 | # ------------------------------------------------------------------------------------------------------ 16 | resource "azurecaf_name" "web_name" { 17 | name = "${var.service_name}-${var.resource_token}" 18 | resource_type = "azurerm_app_service" 19 | random_length = 0 20 | clean_input = true 21 | } 22 | 23 | resource "azurerm_linux_web_app" "web" { 24 | name = azurecaf_name.web_name.result 25 | location = var.location 26 | resource_group_name = var.rg_name 27 | service_plan_id = var.appservice_plan_id 28 | https_only = true 29 | tags = var.tags 30 | 31 | site_config { 32 | always_on = var.always_on 33 | use_32_bit_worker = var.use_32_bit_worker 34 | ftps_state = "FtpsOnly" 35 | app_command_line = var.app_command_line 36 | application_stack { 37 | python_version = var.python_version 38 | } 39 | health_check_path = var.health_check_path 40 | } 41 | 42 | app_settings = var.app_settings 43 | 44 | dynamic "identity" { 45 | for_each = { for k, v in var.identity : k => v if var.identity != [] } 46 | content { 47 | type = identity.value["type"] 48 | } 49 | } 50 | 51 | logs { 52 | application_logs { 53 | file_system_level = "Verbose" 54 | } 55 | detailed_error_messages = true 56 | failed_request_tracing = true 57 | http_logs { 58 | file_system { 59 | retention_in_days = 1 60 | retention_in_mb = 35 61 | } 62 | } 63 | } 64 | } 65 | 66 | # This is a temporary solution until the azurerm provider supports the basicPublishingCredentialsPolicies resource type 67 | resource "null_resource" "webapp_basic_auth_disable" { 68 | triggers = { 69 | account = azurerm_linux_web_app.web.name 70 | } 71 | 72 | provisioner "local-exec" { 73 | command = "az resource update --resource-group ${var.rg_name} --name ftp --namespace Microsoft.Web --resource-type basicPublishingCredentialsPolicies --parent sites/${azurerm_linux_web_app.web.name} --set properties.allow=false && az resource update --resource-group ${var.rg_name} --name scm --namespace Microsoft.Web --resource-type basicPublishingCredentialsPolicies --parent sites/${azurerm_linux_web_app.web.name} --set properties.allow=false" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /infra/modules/appservicepython/appservicepython_output.tf: -------------------------------------------------------------------------------- 1 | output "URI" { 2 | value = "https://${azurerm_linux_web_app.web.default_hostname}" 3 | } 4 | 5 | output "IDENTITY_PRINCIPAL_ID" { 6 | value = length(azurerm_linux_web_app.web.identity) == 0 ? "" : azurerm_linux_web_app.web.identity.0.principal_id 7 | sensitive = true 8 | } 9 | output "APPSERVICE_NAME" { 10 | value = azurerm_linux_web_app.web.name 11 | } 12 | -------------------------------------------------------------------------------- /infra/modules/appservicepython/appservicepython_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "appservice_plan_id" { 12 | description = "The id of the appservice plan to use." 13 | type = string 14 | } 15 | 16 | variable "service_name" { 17 | description = "A name to reflect the type of the app service e.g: web, api." 18 | type = string 19 | } 20 | 21 | variable "app_settings" { 22 | description = "A list of app settings pairs to be assigned to the app service" 23 | type = map(string) 24 | } 25 | 26 | variable "identity" { 27 | description = "A list of application identity" 28 | type = list(any) 29 | default = [] 30 | } 31 | 32 | variable "app_command_line" { 33 | description = "The cmd line to configure the app to run." 34 | type = string 35 | } 36 | 37 | variable "tags" { 38 | description = "A list of tags used for deployed services." 39 | type = map(string) 40 | } 41 | 42 | variable "resource_token" { 43 | description = "A suffix string to centrally mitigate resource name collisions." 44 | type = string 45 | } 46 | 47 | variable "python_version" { 48 | description = "the application stack python version to set for the app service." 49 | type = string 50 | default = "3.10" 51 | } 52 | 53 | variable "always_on" { 54 | description = "The always on setting for the app service." 55 | type = bool 56 | default = true 57 | } 58 | 59 | variable "use_32_bit_worker" { 60 | description = "The use 32 bit worker setting for the app service." 61 | type = bool 62 | default = false 63 | } 64 | 65 | variable "health_check_path" { 66 | description = "The path to the health check endpoint" 67 | type = string 68 | default = "" 69 | } 70 | -------------------------------------------------------------------------------- /infra/modules/cosmos/cosmos.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | # ------------------------------------------------------------------------------------------------------ 14 | # Deploy cosmos db account 15 | # ------------------------------------------------------------------------------------------------------ 16 | resource "azurecaf_name" "db_acc_name" { 17 | name = var.resource_token 18 | resource_type = "azurerm_cosmosdb_account" 19 | random_length = 0 20 | clean_input = true 21 | } 22 | 23 | resource "azurerm_cosmosdb_account" "db" { 24 | name = azurecaf_name.db_acc_name.result 25 | location = var.location 26 | resource_group_name = var.rg_name 27 | offer_type = "Standard" 28 | kind = "MongoDB" 29 | enable_automatic_failover = false 30 | enable_multiple_write_locations = false 31 | mongo_server_version = "4.0" 32 | tags = var.tags 33 | 34 | capabilities { 35 | name = "EnableServerless" 36 | } 37 | 38 | lifecycle { 39 | ignore_changes = [capabilities] 40 | } 41 | consistency_policy { 42 | consistency_level = "Session" 43 | } 44 | 45 | geo_location { 46 | location = var.location 47 | failover_priority = 0 48 | zone_redundant = false 49 | } 50 | } 51 | 52 | # ------------------------------------------------------------------------------------------------------ 53 | # Deploy cosmos mongo db and collections 54 | # ------------------------------------------------------------------------------------------------------ 55 | resource "azurerm_cosmosdb_mongo_database" "mongodb" { 56 | name = "Todo" 57 | resource_group_name = azurerm_cosmosdb_account.db.resource_group_name 58 | account_name = azurerm_cosmosdb_account.db.name 59 | } 60 | 61 | resource "azurerm_cosmosdb_mongo_collection" "list" { 62 | name = "TodoList" 63 | resource_group_name = azurerm_cosmosdb_account.db.resource_group_name 64 | account_name = azurerm_cosmosdb_account.db.name 65 | database_name = azurerm_cosmosdb_mongo_database.mongodb.name 66 | shard_key = "_id" 67 | 68 | 69 | index { 70 | keys = ["_id"] 71 | } 72 | } 73 | 74 | resource "azurerm_cosmosdb_mongo_collection" "item" { 75 | name = "TodoItem" 76 | resource_group_name = azurerm_cosmosdb_account.db.resource_group_name 77 | account_name = azurerm_cosmosdb_account.db.name 78 | database_name = azurerm_cosmosdb_mongo_database.mongodb.name 79 | shard_key = "_id" 80 | 81 | index { 82 | keys = ["_id"] 83 | } 84 | } -------------------------------------------------------------------------------- /infra/modules/cosmos/cosmos_output.tf: -------------------------------------------------------------------------------- 1 | output "AZURE_COSMOS_CONNECTION_STRING" { 2 | value = azurerm_cosmosdb_account.db.connection_strings[0] 3 | sensitive = true 4 | } 5 | 6 | output "AZURE_COSMOS_DATABASE_NAME" { 7 | value = azurerm_cosmosdb_mongo_database.mongodb.name 8 | } -------------------------------------------------------------------------------- /infra/modules/cosmos/cosmos_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "tags" { 12 | description = "A list of tags used for deployed services." 13 | type = map(string) 14 | } 15 | 16 | variable "resource_token" { 17 | description = "A suffix string to centrally mitigate resource name collisions." 18 | type = string 19 | } -------------------------------------------------------------------------------- /infra/modules/keyvault/keyvault.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | 14 | data "azurerm_client_config" "current" {} 15 | # ------------------------------------------------------------------------------------------------------ 16 | # DEPLOY AZURE KEYVAULT 17 | # ------------------------------------------------------------------------------------------------------ 18 | resource "azurecaf_name" "kv_name" { 19 | name = var.resource_token 20 | resource_type = "azurerm_key_vault" 21 | random_length = 0 22 | clean_input = true 23 | } 24 | 25 | resource "azurerm_key_vault" "kv" { 26 | name = azurecaf_name.kv_name.result 27 | location = var.location 28 | resource_group_name = var.rg_name 29 | tenant_id = data.azurerm_client_config.current.tenant_id 30 | purge_protection_enabled = false 31 | sku_name = "standard" 32 | 33 | tags = var.tags 34 | } 35 | 36 | resource "azurerm_key_vault_access_policy" "app" { 37 | count = length(var.access_policy_object_ids) 38 | key_vault_id = azurerm_key_vault.kv.id 39 | tenant_id = data.azurerm_client_config.current.tenant_id 40 | object_id = var.access_policy_object_ids[count.index] 41 | 42 | secret_permissions = [ 43 | "Get", 44 | "Set", 45 | "List", 46 | "Delete", 47 | ] 48 | } 49 | 50 | resource "azurerm_key_vault_access_policy" "user" { 51 | count = var.principal_id == "" ? 0 : 1 52 | key_vault_id = azurerm_key_vault.kv.id 53 | tenant_id = data.azurerm_client_config.current.tenant_id 54 | object_id = var.principal_id 55 | 56 | secret_permissions = [ 57 | "Get", 58 | "Set", 59 | "List", 60 | "Delete", 61 | "Purge" 62 | ] 63 | } 64 | 65 | resource "azurerm_key_vault_secret" "secrets" { 66 | count = length(var.secrets) 67 | name = var.secrets[count.index].name 68 | value = var.secrets[count.index].value 69 | key_vault_id = azurerm_key_vault.kv.id 70 | depends_on = [ 71 | azurerm_key_vault_access_policy.user, 72 | azurerm_key_vault_access_policy.app 73 | ] 74 | } -------------------------------------------------------------------------------- /infra/modules/keyvault/keyvault_output.tf: -------------------------------------------------------------------------------- 1 | output "AZURE_KEY_VAULT_ENDPOINT" { 2 | value = azurerm_key_vault.kv.vault_uri 3 | sensitive = true 4 | } -------------------------------------------------------------------------------- /infra/modules/keyvault/keyvault_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "tags" { 12 | description = "A list of tags used for deployed services." 13 | type = map(string) 14 | } 15 | 16 | variable "resource_token" { 17 | description = "A suffix string to centrally mitigate resource name collisions." 18 | type = string 19 | } 20 | 21 | variable "principal_id" { 22 | description = "The Id of the service principal to add to deployed keyvault access policies" 23 | sensitive = true 24 | type = string 25 | } 26 | 27 | variable "access_policy_object_ids" { 28 | description = "A list of object ids to be be added to the keyvault access policies" 29 | type = list(string) 30 | sensitive = true 31 | default = [] 32 | } 33 | 34 | variable "secrets" { 35 | description = "A list of secrets to be added to the keyvault" 36 | type = list(object({ 37 | name = string 38 | value = string 39 | })) 40 | sensitive = true 41 | } -------------------------------------------------------------------------------- /infra/modules/loganalytics/loganalytics.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | version = "~>3.97.1" 5 | source = "hashicorp/azurerm" 6 | } 7 | azurecaf = { 8 | source = "aztfmod/azurecaf" 9 | version = "~>1.2.24" 10 | } 11 | } 12 | } 13 | # ------------------------------------------------------------------------------------------------------ 14 | # Deploy log analytics workspace 15 | # ------------------------------------------------------------------------------------------------------ 16 | resource "azurecaf_name" "workspace_name" { 17 | name = var.resource_token 18 | resource_type = "azurerm_log_analytics_workspace" 19 | random_length = 0 20 | clean_input = true 21 | } 22 | 23 | resource "azurerm_log_analytics_workspace" "workspace" { 24 | name = azurecaf_name.workspace_name.result 25 | location = var.location 26 | resource_group_name = var.rg_name 27 | sku = "PerGB2018" 28 | retention_in_days = 30 29 | tags = var.tags 30 | } 31 | -------------------------------------------------------------------------------- /infra/modules/loganalytics/loganalytics_output.tf: -------------------------------------------------------------------------------- 1 | output "LOGANALYTICS_WORKSPACE_ID" { 2 | value = azurerm_log_analytics_workspace.workspace.id 3 | } -------------------------------------------------------------------------------- /infra/modules/loganalytics/loganalytics_variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "rg_name" { 7 | description = "The name of the resource group to deploy resources into" 8 | type = string 9 | } 10 | 11 | variable "resource_token" { 12 | description = "A suffix string to centrally mitigate resource name collisions." 13 | type = string 14 | } 15 | 16 | variable "tags" { 17 | description = "A list of tags used for deployed services." 18 | type = map(string) 19 | } -------------------------------------------------------------------------------- /infra/output.tf: -------------------------------------------------------------------------------- 1 | output "AZURE_COSMOS_CONNECTION_STRING_KEY" { 2 | value = local.cosmos_connection_string_key 3 | } 4 | 5 | output "AZURE_COSMOS_DATABASE_NAME" { 6 | value = module.cosmos.AZURE_COSMOS_DATABASE_NAME 7 | } 8 | 9 | output "AZURE_KEY_VAULT_ENDPOINT" { 10 | value = module.keyvault.AZURE_KEY_VAULT_ENDPOINT 11 | sensitive = true 12 | } 13 | 14 | output "REACT_APP_WEB_BASE_URL" { 15 | value = module.web.URI 16 | } 17 | 18 | output "API_BASE_URL" { 19 | value = var.useAPIM ? module.apimApi[0].SERVICE_API_URI : module.api.URI 20 | } 21 | 22 | output "AZURE_LOCATION" { 23 | value = var.location 24 | } 25 | 26 | output "APPLICATIONINSIGHTS_CONNECTION_STRING" { 27 | value = module.applicationinsights.APPLICATIONINSIGHTS_CONNECTION_STRING 28 | sensitive = true 29 | } 30 | 31 | output "USE_APIM" { 32 | value = var.useAPIM 33 | } 34 | 35 | output "SERVICE_API_ENDPOINTS" { 36 | value = var.useAPIM ? [ module.apimApi[0].SERVICE_API_URI, module.api.URI ] : [] 37 | } 38 | -------------------------------------------------------------------------------- /infra/provider.tf: -------------------------------------------------------------------------------- 1 | #Set the terraform required version, and Configure the Azure Provider.Use local storage 2 | 3 | # Configure the Azure Provider 4 | terraform { 5 | required_version = ">= 1.1.7, < 2.0.0" 6 | required_providers { 7 | azurerm = { 8 | version = "~>3.97.1" 9 | source = "hashicorp/azurerm" 10 | } 11 | azurecaf = { 12 | source = "aztfmod/azurecaf" 13 | version = "~>1.2.24" 14 | } 15 | } 16 | } 17 | 18 | provider "azurerm" { 19 | skip_provider_registration = "true" 20 | features { 21 | key_vault { 22 | purge_soft_delete_on_destroy = false 23 | } 24 | resource_group { 25 | prevent_deletion_if_contains_resources = false 26 | } 27 | } 28 | } 29 | 30 | # Make client_id, tenant_id, subscription_id and object_id variables 31 | data "azurerm_client_config" "current" {} -------------------------------------------------------------------------------- /infra/variables.tf: -------------------------------------------------------------------------------- 1 | variable "location" { 2 | description = "The supported Azure location where the resource deployed" 3 | type = string 4 | } 5 | 6 | variable "environment_name" { 7 | description = "The name of the azd environment to be deployed" 8 | type = string 9 | } 10 | 11 | variable "principal_id" { 12 | description = "The Id of the azd service principal to add to deployed keyvault access policies" 13 | type = string 14 | default = "" 15 | } 16 | 17 | variable "useAPIM" { 18 | description = "Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API." 19 | type = bool 20 | default = false 21 | } 22 | 23 | variable "apimSKU" { 24 | description = "Azure API Management SKU. Only used if useAPIM is true." 25 | type = string 26 | default = "Consumption" 27 | } 28 | -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/.gitignore: -------------------------------------------------------------------------------- 1 | *env/ 2 | __pycache__ -------------------------------------------------------------------------------- /src/api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: FastAPI", 6 | "type": "python", 7 | "request": "launch", 8 | "module": "uvicorn", 9 | "cwd": "${workspaceFolder}/todo", 10 | "args": [ 11 | "todo.app:app", 12 | "--reload" 13 | ], 14 | }, 15 | { 16 | "name": "Python: Pytest", 17 | "type": "python", 18 | "request": "launch", 19 | "module": "pytest", 20 | "cwd": "${workspaceFolder}/todo", 21 | "args": [ 22 | "${workspaceFolder}/tests", 23 | "-vv" 24 | ], 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdownlint.config": { 3 | "MD028": false, 4 | "MD025": { 5 | "front_matter_title": "" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | WORKDIR /code 3 | EXPOSE 3100 4 | COPY ./requirements.txt /code/requirements.txt 5 | 6 | RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt 7 | 8 | COPY ./todo /code/todo 9 | 10 | CMD ["uvicorn", "todo.app:app", "--host", "0.0.0.0", "--port", "3100", "--proxy-headers"] 11 | -------------------------------------------------------------------------------- /src/api/README.md: -------------------------------------------------------------------------------- 1 | # Python TODO API 2 | 3 | ## Setup 4 | 5 | Requirements: 6 | 7 | - Python (3.8+) 8 | 9 | ```bash 10 | $ pip install -r requirements.txt 11 | ``` 12 | 13 | Or 14 | 15 | ```bash 16 | $ poetry install 17 | ``` 18 | 19 | ## Running 20 | 21 | Before running, set the `AZURE_COSMOS_CONNECTION_STRING` environment variable to the connection-string for mongo/cosmos. 22 | 23 | Run the following common from the root of the api folder to start the app: 24 | 25 | ```bash 26 | $ uvicorn todo.app:app --port 3100 --reload 27 | ``` 28 | 29 | There is also a launch profile in VS Code for debugging. 30 | 31 | ## Running in Docker 32 | 33 | The environment variable AZURE_COSMOS_CONNECTION_STRING must be set and then application runs on TCP 8080: 34 | 35 | ```bash 36 | docker build . -t fastapi-todo 37 | docker run --env-file ./src/.env -p 8080:8080 -t fastapi-todo 38 | ``` 39 | 40 | ## Tests 41 | 42 | The tests can be run from the command line, or the launch profile in VS Code 43 | 44 | ```bash 45 | $ pip install -r requirements-test.txt 46 | $ AZURE_COSMOS_DATABASE_NAME=test_db python -m pytest tests/ 47 | ``` 48 | -------------------------------------------------------------------------------- /src/api/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: Simple Todo API 4 | version: 3.0.0 5 | title: Simple Todo API 6 | contact: 7 | email: azdevteam@microsoft.com 8 | 9 | components: 10 | schemas: 11 | TodoItem: 12 | type: object 13 | required: 14 | - listId 15 | - name 16 | - description 17 | description: A task that needs to be completed 18 | properties: 19 | id: 20 | type: string 21 | listId: 22 | type: string 23 | name: 24 | type: string 25 | description: 26 | type: string 27 | state: 28 | $ref: "#/components/schemas/TodoState" 29 | dueDate: 30 | type: string 31 | format: date-time 32 | completedDate: 33 | type: string 34 | format: date-time 35 | TodoList: 36 | type: object 37 | required: 38 | - name 39 | properties: 40 | id: 41 | type: string 42 | name: 43 | type: string 44 | description: 45 | type: string 46 | description: " A list of related Todo items" 47 | TodoState: 48 | type: string 49 | enum: 50 | - todo 51 | - inprogress 52 | - done 53 | parameters: 54 | listId: 55 | in: path 56 | required: true 57 | name: listId 58 | description: The Todo list unique identifier 59 | schema: 60 | type: string 61 | itemId: 62 | in: path 63 | required: true 64 | name: itemId 65 | description: The Todo item unique identifier 66 | schema: 67 | type: string 68 | state: 69 | in: path 70 | required: true 71 | name: state 72 | description: The Todo item state 73 | schema: 74 | $ref: "#/components/schemas/TodoState" 75 | top: 76 | in: query 77 | required: false 78 | name: top 79 | description: The max number of items to returns in a result 80 | schema: 81 | type: number 82 | default: 20 83 | skip: 84 | in: query 85 | required: false 86 | name: skip 87 | description: The number of items to skip within the results 88 | schema: 89 | type: number 90 | default: 0 91 | 92 | requestBodies: 93 | TodoList: 94 | description: The Todo List 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/TodoList" 99 | TodoItem: 100 | description: The Todo Item 101 | content: 102 | application/json: 103 | schema: 104 | $ref: "#/components/schemas/TodoItem" 105 | 106 | responses: 107 | TodoList: 108 | description: A Todo list result 109 | content: 110 | application/json: 111 | schema: 112 | $ref: "#/components/schemas/TodoList" 113 | TodoListArray: 114 | description: An array of Todo lists 115 | content: 116 | application/json: 117 | schema: 118 | type: array 119 | items: 120 | $ref: "#/components/schemas/TodoList" 121 | TodoItem: 122 | description: A Todo item result 123 | content: 124 | application/json: 125 | schema: 126 | $ref: "#/components/schemas/TodoItem" 127 | TodoItemArray: 128 | description: An array of Todo items 129 | content: 130 | application/json: 131 | schema: 132 | type: array 133 | items: 134 | $ref: "#/components/schemas/TodoItem" 135 | 136 | paths: 137 | /lists: 138 | get: 139 | operationId: GetLists 140 | summary: Gets an array of Todo lists 141 | tags: 142 | - Lists 143 | parameters: 144 | - $ref: "#/components/parameters/top" 145 | - $ref: "#/components/parameters/skip" 146 | responses: 147 | 200: 148 | $ref: "#/components/responses/TodoListArray" 149 | post: 150 | operationId: CreateList 151 | summary: Creates a new Todo list 152 | tags: 153 | - Lists 154 | requestBody: 155 | $ref: "#/components/requestBodies/TodoList" 156 | responses: 157 | 201: 158 | $ref: "#/components/responses/TodoList" 159 | 400: 160 | description: Invalid request schema 161 | /lists/{listId}: 162 | get: 163 | operationId: GetListById 164 | summary: Gets a Todo list by unique identifier 165 | tags: 166 | - Lists 167 | parameters: 168 | - $ref: "#/components/parameters/listId" 169 | responses: 170 | 200: 171 | $ref: "#/components/responses/TodoList" 172 | 404: 173 | description: Todo list not found 174 | put: 175 | operationId: UpdateListById 176 | summary: Updates a Todo list by unique identifier 177 | tags: 178 | - Lists 179 | requestBody: 180 | $ref: "#/components/requestBodies/TodoList" 181 | parameters: 182 | - $ref: "#/components/parameters/listId" 183 | responses: 184 | 200: 185 | $ref: "#/components/responses/TodoList" 186 | 404: 187 | description: Todo list not found 188 | 400: 189 | description: Todo list is invalid 190 | delete: 191 | operationId: DeleteListById 192 | summary: Deletes a Todo list by unique identifier 193 | tags: 194 | - Lists 195 | parameters: 196 | - $ref: "#/components/parameters/listId" 197 | responses: 198 | 204: 199 | description: Todo list deleted successfully 200 | 404: 201 | description: Todo list not found 202 | /lists/{listId}/items: 203 | post: 204 | operationId: CreateItem 205 | summary: Creates a new Todo item within a list 206 | tags: 207 | - Items 208 | requestBody: 209 | $ref: "#/components/requestBodies/TodoItem" 210 | parameters: 211 | - $ref: "#/components/parameters/listId" 212 | responses: 213 | 201: 214 | $ref: "#/components/responses/TodoItem" 215 | 404: 216 | description: Todo list not found 217 | get: 218 | operationId: GetItemsByListId 219 | summary: Gets Todo items within the specified list 220 | tags: 221 | - Items 222 | parameters: 223 | - $ref: "#/components/parameters/listId" 224 | - $ref: "#/components/parameters/top" 225 | - $ref: "#/components/parameters/skip" 226 | responses: 227 | 200: 228 | $ref: "#/components/responses/TodoItemArray" 229 | 404: 230 | description: Todo list not found 231 | /lists/{listId}/items/{itemId}: 232 | get: 233 | operationId: GetItemById 234 | summary: Gets a Todo item by unique identifier 235 | tags: 236 | - Items 237 | parameters: 238 | - $ref: "#/components/parameters/listId" 239 | - $ref: "#/components/parameters/itemId" 240 | responses: 241 | 200: 242 | $ref: "#/components/responses/TodoItem" 243 | 404: 244 | description: Todo list or item not found 245 | put: 246 | operationId: UpdateItemById 247 | summary: Updates a Todo item by unique identifier 248 | tags: 249 | - Items 250 | requestBody: 251 | $ref: "#/components/requestBodies/TodoItem" 252 | parameters: 253 | - $ref: "#/components/parameters/listId" 254 | - $ref: "#/components/parameters/itemId" 255 | responses: 256 | 200: 257 | $ref: "#/components/responses/TodoItem" 258 | 400: 259 | description: Todo item is invalid 260 | 404: 261 | description: Todo list or item not found 262 | delete: 263 | operationId: DeleteItemById 264 | summary: Deletes a Todo item by unique identifier 265 | tags: 266 | - Items 267 | parameters: 268 | - $ref: "#/components/parameters/listId" 269 | - $ref: "#/components/parameters/itemId" 270 | responses: 271 | 204: 272 | description: Todo item deleted successfully 273 | 404: 274 | description: Todo list or item not found 275 | /lists/{listId}/items/state/{state}: 276 | get: 277 | operationId: GetItemsByListIdAndState 278 | summary: Gets a list of Todo items of a specific state 279 | tags: 280 | - Items 281 | parameters: 282 | - $ref: "#/components/parameters/listId" 283 | - $ref: "#/components/parameters/state" 284 | - $ref: "#/components/parameters/top" 285 | - $ref: "#/components/parameters/skip" 286 | responses: 287 | 200: 288 | $ref: "#/components/responses/TodoItemArray" 289 | 404: 290 | description: Todo list or item not found 291 | put: 292 | operationId: UpdateItemsStateByListId 293 | summary: Changes the state of the specified list items 294 | tags: 295 | - Items 296 | requestBody: 297 | description: unique identifiers of the Todo items to update 298 | content: 299 | application/json: 300 | schema: 301 | type: array 302 | items: 303 | description: The Todo item unique identifier 304 | type: string 305 | parameters: 306 | - $ref: "#/components/parameters/listId" 307 | - $ref: "#/components/parameters/state" 308 | responses: 309 | 204: 310 | description: Todo items updated 311 | 400: 312 | description: Update request is invalid 313 | -------------------------------------------------------------------------------- /src/api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "todo" 3 | version = "0.1.0" 4 | description = "Simple Todo API" 5 | license = "MIT" 6 | packages = [ 7 | { include = "todo" }, 8 | ] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | fastapi = "*" 13 | uvicorn = "*" 14 | beanie = "*" 15 | python-dotenv = "*" 16 | 17 | [tool.poetry.dev-dependencies] 18 | pytest = "*" 19 | pytest-asyncio = "*" 20 | 21 | [build-system] 22 | requires = ["poetry-core>=1.0.0"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /src/api/requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest>5 2 | pytest-asyncio -------------------------------------------------------------------------------- /src/api/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi == 0.95.* 2 | uvicorn == 0.19.* 3 | beanie == 1.11.* 4 | python-dotenv == 0.20.* 5 | # 1.13.0b4 has a update supporting configurable timeouts on AzureDeveloperCredential 6 | # https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/azure-identity/CHANGELOG.md#1130b4-2023-04-11 7 | azure-identity == 1.21.0 8 | azure-keyvault-secrets == 4.4.* 9 | opentelemetry-instrumentation-fastapi == 0.42b0 10 | azure-monitor-opentelemetry-exporter == 1.0.0b19 11 | -------------------------------------------------------------------------------- /src/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import motor 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | from todo.app import app, settings 7 | 8 | TEST_DB_NAME = "test_db" 9 | 10 | 11 | @pytest.fixture(scope="session") 12 | def event_loop(): 13 | """ 14 | Redefine the event_loop fixture to be session scoped. 15 | Requirement of pytest-asyncio if there are async fixtures 16 | with non-function scope. 17 | """ 18 | try: 19 | return asyncio.get_running_loop() 20 | except RuntimeError: 21 | return asyncio.new_event_loop() 22 | 23 | 24 | @pytest.fixture() 25 | def app_client(): 26 | with TestClient(app) as client: 27 | yield client 28 | 29 | 30 | @pytest.fixture(scope="session", autouse=True) 31 | async def initialize_database(): 32 | settings.AZURE_COSMOS_DATABASE_NAME = TEST_DB_NAME 33 | mongo_client = motor.motor_asyncio.AsyncIOMotorClient( 34 | settings.AZURE_COSMOS_CONNECTION_STRING 35 | ) 36 | await mongo_client.drop_database(TEST_DB_NAME) 37 | yield 38 | await mongo_client.drop_database(TEST_DB_NAME) 39 | -------------------------------------------------------------------------------- /src/api/tests/test_main.py: -------------------------------------------------------------------------------- 1 | def test_list(app_client): 2 | # Create 2 test locations 3 | first = app_client.post( 4 | "/lists", 5 | json={ 6 | "name": "Test List 1", 7 | "description": "My first test list", 8 | }, 9 | ) 10 | assert first.status_code == 201 11 | assert first.headers["Location"].startswith("http://testserver/lists/") 12 | assert ( 13 | app_client.post( 14 | "/lists", 15 | json={ 16 | "name": "Test List 2", 17 | "description": "My second test list", 18 | }, 19 | ).status_code 20 | == 201 21 | ) 22 | 23 | # Get all lists 24 | response = app_client.get("/lists") 25 | assert response.status_code == 200 26 | result = response.json() 27 | assert len(result) == 2 28 | assert result[0]["name"] == "Test List 1" 29 | assert result[1]["name"] == "Test List 2" 30 | assert result[0]["createdDate"] is not None 31 | assert result[1]["createdDate"] is not None 32 | 33 | # Test those lists at the ID URL 34 | assert result[0]["id"] is not None 35 | test_list_id = result[0]["id"] 36 | test_list_id2 = result[1]["id"] 37 | 38 | response = app_client.get("/lists/{0}".format(test_list_id)) 39 | assert response.status_code == 200 40 | result = response.json() 41 | assert result["name"] == "Test List 1" 42 | assert result["description"] == "My first test list" 43 | 44 | # Test a list with a bad ID 45 | response = app_client.get("/lists/{0}".format("61958439e0dbd854f5ab9000")) 46 | assert response.status_code == 404 47 | 48 | # Update a list 49 | response = app_client.put( 50 | "/lists/{0}".format(test_list_id), 51 | json={ 52 | "name": "Test List 1 Updated", 53 | }, 54 | ) 55 | assert response.status_code == 200 56 | result = response.json() 57 | assert result["name"] == "Test List 1 Updated" 58 | assert result["updatedDate"] is not None 59 | 60 | # Delete a list 61 | response = app_client.delete("/lists/{0}".format(test_list_id2)) 62 | assert response.status_code == 204 63 | response = app_client.get("/lists/{0}".format(test_list_id2)) 64 | assert response.status_code == 404 65 | 66 | # Create a list item 67 | response = app_client.post( 68 | "/lists/{0}/items".format(test_list_id), 69 | json={ 70 | "name": "Test Item 1", 71 | "description": "My first test item", 72 | }, 73 | ) 74 | assert response.status_code == 201 75 | assert response.headers["Location"].startswith("http://testserver/lists/") 76 | assert "items/" in response.headers["Location"] 77 | 78 | 79 | # Get all list items 80 | response = app_client.get("/lists/{0}/items".format(test_list_id)) 81 | assert response.status_code == 200 82 | result = response.json() 83 | assert len(result) == 1 84 | assert result[0]["name"] == "Test Item 1" 85 | assert result[0]["description"] == "My first test item" 86 | assert result[0]["createdDate"] is not None 87 | test_item_id = result[0]["id"] 88 | 89 | # Update list item 90 | response = app_client.put( 91 | "/lists/{0}/items/{1}".format(test_list_id, test_item_id), 92 | json={ 93 | "name": "Test Item 1 Updated", 94 | "description": "My first test item", 95 | "state": "done", 96 | }, 97 | ) 98 | assert response.status_code == 200, response.text 99 | 100 | # Get list item by id 101 | response = app_client.get("/lists/{0}/items/{1}".format(test_list_id, test_item_id)) 102 | assert response.status_code == 200 103 | result = response.json() 104 | assert result["name"] == "Test Item 1 Updated" 105 | assert result["state"] == "done" 106 | assert result["updatedDate"] is not None 107 | 108 | # Get list items by state 109 | response = app_client.get("/lists/{0}/items/state/done".format(test_list_id)) 110 | assert response.status_code == 200 111 | result = response.json() 112 | assert len(result) == 1 113 | assert result[0]["name"] == "Test Item 1 Updated" 114 | assert result[0]["state"] == "done" 115 | 116 | # Delete list item 117 | response = app_client.delete( 118 | "/lists/{0}/items/{1}".format(test_list_id, test_item_id) 119 | ) 120 | assert response.status_code == 204 121 | -------------------------------------------------------------------------------- /src/api/todo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/e8c10a1d31e56ced02d9acf630881634da04f265/src/api/todo/__init__.py -------------------------------------------------------------------------------- /src/api/todo/app.py: -------------------------------------------------------------------------------- 1 | import motor 2 | from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter 3 | from beanie import init_beanie 4 | from fastapi import FastAPI 5 | from fastapi.middleware.cors import CORSMiddleware 6 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor 7 | from opentelemetry.sdk.resources import SERVICE_NAME, Resource 8 | from opentelemetry.sdk.trace import TracerProvider 9 | from opentelemetry.sdk.trace.export import BatchSpanProcessor 10 | import os 11 | from pathlib import Path 12 | 13 | # Use API_ALLOW_ORIGINS env var with comma separated urls like 14 | # `http://localhost:300, http://otherurl:100` 15 | # Requests coming to the api server from other urls will be rejected as per 16 | # CORS. 17 | allowOrigins = os.environ.get('API_ALLOW_ORIGINS') 18 | 19 | # Use API_ENVIRONMENT to change webConfiguration based on this value. 20 | # For example, setting API_ENVIRONMENT=develop disables CORS checking, 21 | # allowing all origins. 22 | environment = os.environ.get('API_ENVIRONMENT') 23 | 24 | def originList(): 25 | if environment is not None and environment == "develop": 26 | print("Allowing requests from any origins. API_ENVIRONMENT=", environment) 27 | return ["*"] 28 | 29 | origins = [ 30 | "https://portal.azure.com", 31 | "https://ms.portal.azure.com", 32 | ] 33 | 34 | if allowOrigins is not None: 35 | for origin in allowOrigins.split(","): 36 | print("Allowing requests from", origin, ". To change or disable, go to ", Path(__file__)) 37 | origins.append(origin) 38 | 39 | return origins 40 | 41 | from .models import Settings, __beanie_models__ 42 | 43 | settings = Settings() 44 | app = FastAPI( 45 | description="Simple Todo API", 46 | version="2.0.0", 47 | title="Simple Todo API", 48 | docs_url="/", 49 | ) 50 | app.add_middleware( 51 | CORSMiddleware, 52 | allow_origins=originList(), 53 | allow_credentials=True, 54 | allow_methods=["*"], 55 | allow_headers=["*"], 56 | ) 57 | 58 | if settings.APPLICATIONINSIGHTS_CONNECTION_STRING: 59 | exporter = AzureMonitorTraceExporter.from_connection_string( 60 | settings.APPLICATIONINSIGHTS_CONNECTION_STRING 61 | ) 62 | tracerProvider = TracerProvider( 63 | resource=Resource({SERVICE_NAME: settings.APPLICATIONINSIGHTS_ROLENAME}) 64 | ) 65 | tracerProvider.add_span_processor(BatchSpanProcessor(exporter)) 66 | 67 | FastAPIInstrumentor.instrument_app(app, tracer_provider=tracerProvider) 68 | 69 | 70 | from . import routes # NOQA 71 | 72 | @app.on_event("startup") 73 | async def startup_event(): 74 | client = motor.motor_asyncio.AsyncIOMotorClient( 75 | settings.AZURE_COSMOS_CONNECTION_STRING 76 | ) 77 | await init_beanie( 78 | database=client[settings.AZURE_COSMOS_DATABASE_NAME], 79 | document_models=__beanie_models__, 80 | ) 81 | -------------------------------------------------------------------------------- /src/api/todo/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Optional 4 | 5 | from azure.identity import DefaultAzureCredential 6 | from azure.keyvault.secrets import SecretClient 7 | from beanie import Document, PydanticObjectId 8 | from pydantic import BaseModel, BaseSettings 9 | 10 | def keyvault_name_as_attr(name: str) -> str: 11 | return name.replace("-", "_").upper() 12 | 13 | 14 | class Settings(BaseSettings): 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | 18 | # Load secrets from keyvault 19 | if self.AZURE_KEY_VAULT_ENDPOINT: 20 | credential = DefaultAzureCredential() 21 | keyvault_client = SecretClient(self.AZURE_KEY_VAULT_ENDPOINT, credential) 22 | for secret in keyvault_client.list_properties_of_secrets(): 23 | setattr( 24 | self, 25 | keyvault_name_as_attr(secret.name), 26 | keyvault_client.get_secret(secret.name).value, 27 | ) 28 | 29 | AZURE_COSMOS_CONNECTION_STRING: str = "" 30 | AZURE_COSMOS_DATABASE_NAME: str = "Todo" 31 | AZURE_KEY_VAULT_ENDPOINT: Optional[str] = None 32 | APPLICATIONINSIGHTS_CONNECTION_STRING: Optional[str] = None 33 | APPLICATIONINSIGHTS_ROLENAME: Optional[str] = "API" 34 | 35 | class Config: 36 | env_file = ".env" 37 | env_file_encoding = "utf-8" 38 | 39 | 40 | class TodoList(Document): 41 | name: str 42 | description: Optional[str] = None 43 | createdDate: Optional[datetime] = None 44 | updatedDate: Optional[datetime] = None 45 | 46 | 47 | class CreateUpdateTodoList(BaseModel): 48 | name: str 49 | description: Optional[str] = None 50 | 51 | 52 | class TodoState(Enum): 53 | TODO = "todo" 54 | INPROGRESS = "inprogress" 55 | DONE = "done" 56 | 57 | 58 | class TodoItem(Document): 59 | listId: PydanticObjectId 60 | name: str 61 | description: Optional[str] = None 62 | state: Optional[TodoState] = None 63 | dueDate: Optional[datetime] = None 64 | completedDate: Optional[datetime] = None 65 | createdDate: Optional[datetime] = None 66 | updatedDate: Optional[datetime] = None 67 | 68 | 69 | class CreateUpdateTodoItem(BaseModel): 70 | name: str 71 | description: Optional[str] = None 72 | state: Optional[TodoState] = None 73 | dueDate: Optional[datetime] = None 74 | completedDate: Optional[datetime] = None 75 | 76 | 77 | __beanie_models__ = [TodoList, TodoItem] 78 | -------------------------------------------------------------------------------- /src/api/todo/routes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from http import HTTPStatus 3 | from typing import List, Optional 4 | from urllib.parse import urljoin 5 | 6 | from beanie import PydanticObjectId 7 | from fastapi import HTTPException, Response 8 | from starlette.requests import Request 9 | 10 | from .app import app 11 | from .models import (CreateUpdateTodoItem, CreateUpdateTodoList, TodoItem, 12 | TodoList, TodoState) 13 | 14 | 15 | @app.get("/lists", response_model=List[TodoList], response_model_by_alias=False) 16 | async def get_lists( 17 | top: Optional[int] = None, skip: Optional[int] = None 18 | ) -> List[TodoList]: 19 | """ 20 | Get all Todo lists 21 | 22 | Optional arguments: 23 | 24 | - **top**: Number of lists to return 25 | - **skip**: Number of lists to skip 26 | """ 27 | query = TodoList.all().skip(skip).limit(top) 28 | return await query.to_list() 29 | 30 | 31 | @app.post("/lists", response_model=TodoList, response_model_by_alias=False, status_code=201) 32 | async def create_list(body: CreateUpdateTodoList, request: Request, response: Response) -> TodoList: 33 | """ 34 | Create a new Todo list 35 | """ 36 | todo_list = await TodoList(**body.dict(), createdDate=datetime.utcnow()).save() 37 | response.headers["Location"] = urljoin(str(request.base_url), "lists/{0}".format(str(todo_list.id))) 38 | return todo_list 39 | 40 | 41 | @app.get("/lists/{list_id}", response_model=TodoList, response_model_by_alias=False) 42 | async def get_list(list_id: PydanticObjectId) -> TodoList: 43 | """ 44 | Get Todo list by ID 45 | """ 46 | todo_list = await TodoList.get(document_id=list_id) 47 | if not todo_list: 48 | raise HTTPException(status_code=404, detail="Todo list not found") 49 | return todo_list 50 | 51 | 52 | @app.put("/lists/{list_id}", response_model=TodoList, response_model_by_alias=False) 53 | async def update_list( 54 | list_id: PydanticObjectId, body: CreateUpdateTodoList 55 | ) -> TodoList: 56 | """ 57 | Updates a Todo list by unique identifier 58 | """ 59 | todo_list = await TodoList.get(document_id=list_id) 60 | if not todo_list: 61 | raise HTTPException(status_code=404, detail="Todo list not found") 62 | await todo_list.update({"$set": body.dict(exclude_unset=True)}) 63 | todo_list.updatedDate = datetime.utcnow() 64 | return await todo_list.save() 65 | 66 | 67 | @app.delete("/lists/{list_id}", response_class=Response, status_code=204) 68 | async def delete_list(list_id: PydanticObjectId) -> None: 69 | """ 70 | Deletes a Todo list by unique identifier 71 | """ 72 | todo_list = await TodoList.get(document_id=list_id) 73 | if not todo_list: 74 | raise HTTPException(status_code=404, detail="Todo list not found") 75 | await todo_list.delete() 76 | 77 | 78 | @app.post("/lists/{list_id}/items", response_model=TodoItem, response_model_by_alias=False, status_code=201) 79 | async def create_list_item( 80 | list_id: PydanticObjectId, body: CreateUpdateTodoItem, request: Request, response: Response 81 | ) -> TodoItem: 82 | """ 83 | Creates a new Todo item within a list 84 | """ 85 | item = TodoItem(listId=list_id, **body.dict(), createdDate=datetime.utcnow()) 86 | response.headers["Location"] = urljoin(str(request.base_url), "lists/{0}/items/{1}".format(str(list_id), str(item.id))) 87 | return await item.save() 88 | 89 | 90 | @app.get("/lists/{list_id}/items", response_model=List[TodoItem], response_model_by_alias=False) 91 | async def get_list_items( 92 | list_id: PydanticObjectId, 93 | top: Optional[int] = None, 94 | skip: Optional[int] = None, 95 | ) -> List[TodoItem]: 96 | """ 97 | Gets Todo items within the specified list 98 | 99 | Optional arguments: 100 | 101 | - **top**: Number of lists to return 102 | - **skip**: Number of lists to skip 103 | """ 104 | query = TodoItem.find(TodoItem.listId == list_id).skip(skip).limit(top) 105 | return await query.to_list() 106 | 107 | 108 | @app.get("/lists/{list_id}/items/state/{state}", response_model=List[TodoItem], response_model_by_alias=False) 109 | async def get_list_items_by_state( 110 | list_id: PydanticObjectId, 111 | state: TodoState = ..., 112 | top: Optional[int] = None, 113 | skip: Optional[int] = None, 114 | ) -> List[TodoItem]: 115 | """ 116 | Gets a list of Todo items of a specific state 117 | 118 | Optional arguments: 119 | 120 | - **top**: Number of lists to return 121 | - **skip**: Number of lists to skip 122 | """ 123 | query = ( 124 | TodoItem.find(TodoItem.listId == list_id, TodoItem.state == state) 125 | .skip(skip) 126 | .limit(top) 127 | ) 128 | return await query.to_list() 129 | 130 | 131 | @app.put("/lists/{list_id}/items/state/{state}", response_model=List[TodoItem], response_model_by_alias=False) 132 | async def update_list_items_state( 133 | list_id: PydanticObjectId, 134 | state: TodoState = ..., 135 | body: List[str] = None, 136 | ) -> List[TodoItem]: 137 | """ 138 | Changes the state of the specified list items 139 | """ 140 | if not body: 141 | raise HTTPException(status_code=400, detail="No items specified") 142 | results = [] 143 | for id_ in body: 144 | item = await TodoItem.get(document_id=id_) 145 | if not item: 146 | raise HTTPException(status_code=404, detail="Todo item not found") 147 | item.state = state 148 | item.updatedDate = datetime.utcnow() 149 | results.append(await item.save()) 150 | return results 151 | 152 | 153 | @app.get("/lists/{list_id}/items/{item_id}", response_model=TodoItem, response_model_by_alias=False) 154 | async def get_list_item( 155 | list_id: PydanticObjectId, item_id: PydanticObjectId 156 | ) -> TodoItem: 157 | """ 158 | Gets a Todo item by unique identifier 159 | """ 160 | item = await TodoItem.find_one(TodoItem.listId == list_id, TodoItem.id == item_id) 161 | if not item: 162 | raise HTTPException(status_code=404, detail="Todo item not found") 163 | return item 164 | 165 | 166 | @app.put("/lists/{list_id}/items/{item_id}", response_model=TodoItem, response_model_by_alias=False) 167 | async def update_list_item( 168 | list_id: PydanticObjectId, 169 | item_id: PydanticObjectId, 170 | body: CreateUpdateTodoItem, 171 | ) -> TodoItem: 172 | """ 173 | Updates a Todo item by unique identifier 174 | """ 175 | item = await TodoItem.find_one(TodoItem.listId == list_id, TodoItem.id == item_id) 176 | if not item: 177 | raise HTTPException(status_code=404, detail="Todo item not found") 178 | await item.update({"$set": body.dict(exclude_unset=True)}) 179 | item.updatedDate = datetime.utcnow() 180 | return await item.save() 181 | 182 | 183 | @app.delete("/lists/{list_id}/items/{item_id}", response_class=Response, status_code=204) 184 | async def delete_list_item( 185 | list_id: PydanticObjectId, item_id: PydanticObjectId 186 | ) -> None: 187 | """ 188 | Deletes a Todo item by unique identifier 189 | """ 190 | todo_item = await TodoItem.find_one(TodoItem.id == item_id) 191 | if not todo_item: 192 | raise HTTPException(status_code=404, detail="Todo item not found") 193 | await todo_item.delete() 194 | -------------------------------------------------------------------------------- /src/web/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.devcontainer 2 | **/build 3 | **/node_modules 4 | README.md 5 | -------------------------------------------------------------------------------- /src/web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .eslintcache 25 | 26 | env-config.js -------------------------------------------------------------------------------- /src/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS build 2 | 3 | # make the 'app' folder the current working directory 4 | WORKDIR /app 5 | 6 | COPY . . 7 | 8 | # install project dependencies 9 | RUN npm ci 10 | RUN npm run build 11 | 12 | FROM nginx:alpine 13 | 14 | WORKDIR /usr/share/nginx/html 15 | COPY --from=build /app/dist . 16 | COPY --from=build /app/nginx/nginx.conf /etc/nginx/conf.d/default.conf 17 | 18 | EXPOSE 80 19 | 20 | CMD ["/bin/sh", "-c", "nginx -g \"daemon off;\""] 21 | -------------------------------------------------------------------------------- /src/web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App and Fluent UI 2 | 3 | This is a [Create React App](https://github.com/facebook/create-react-app) based repo that comes with Fluent UI pre-installed! 4 | 5 | ## Setup 6 | 7 | Create a `.env` file within the base of the `reactd-fluent` folder with the following configuration: 8 | 9 | - `VITE_API_BASE_URL` - Base URL for all api requests, (ex: `http://localhost:3100`) 10 | 11 | > Note: The URL must include the schema, either `http://` or `https://`. 12 | 13 | - `VITE_APPLICATIONINSIGHTS_CONNECTION_STRING` - Azure Application Insights connection string 14 | 15 | ## Available Scripts 16 | 17 | In the project directory, you can run: 18 | 19 | ### `npm ci` 20 | 21 | Installs local pre-requisites. 22 | 23 | ### `npm start` 24 | 25 | Runs the app in the development mode. 26 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 27 | 28 | The page will reload if you make edits. 29 | You will also see any lint errors in the console. 30 | 31 | ### `npm test` 32 | 33 | Launches the test runner in the interactive watch mode. 34 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 35 | 36 | ### `npm run build` 37 | 38 | Builds the app for production to the `build` folder. 39 | It correctly bundles React in production mode and optimizes the build for the best performance. 40 | 41 | The build is minified and the filenames include the hashes. 42 | Your app is ready to be deployed! 43 | 44 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 45 | 46 | ### `npm run eject` 47 | 48 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 49 | 50 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 51 | 52 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 53 | 54 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 55 | 56 | ## Learn More 57 | 58 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 59 | 60 | To learn React, check out the [React documentation](https://reactjs.org/). 61 | 62 | ## Contributing 63 | 64 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 65 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 66 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 67 | 68 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 69 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 70 | provided by the bot. You will only need to do this once across all repos using our CLA. 71 | 72 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 73 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 74 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 75 | -------------------------------------------------------------------------------- /src/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 23 | 24 | AzDev Todo 25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/web/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | root /usr/share/nginx/html; 5 | index index.html index.htm; 6 | try_files $uri $uri/ /index.html =404; 7 | } 8 | } -------------------------------------------------------------------------------- /src/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 14 | "@fluentui/react": "^8.73.0", 15 | "@microsoft/applicationinsights-react-js": "^17.0.3", 16 | "@microsoft/applicationinsights-web": "^3.0.7", 17 | "axios": "^1.8.2", 18 | "history": "^5.3.0", 19 | "dotenv": "^16.3.1", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-router-dom": "^7.5.2", 23 | "web-vitals": "^3.5.1" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.43", 27 | "@types/react-dom": "^18.2.17", 28 | "@typescript-eslint/eslint-plugin": "^6.14.0", 29 | "@typescript-eslint/parser": "^6.14.0", 30 | "@vitejs/plugin-react-swc": "^3.8.0", 31 | "eslint": "^8.55.0", 32 | "eslint-plugin-react-hooks": "^4.6.0", 33 | "eslint-plugin-react-refresh": "^0.4.5", 34 | "typescript": "^5.2.2", 35 | "vite": "^6.3.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/e8c10a1d31e56ced02d9acf630881634da04f265/src/web/public/favicon.ico -------------------------------------------------------------------------------- /src/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#0392ff", 14 | "background_color": "#fcfcfc" 15 | } 16 | -------------------------------------------------------------------------------- /src/web/src/@types/window.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_API_BASE_URL: string; 5 | readonly VITE_APPLICATIONINSIGHTS_CONNECTION_STRING: string; 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /src/web/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | height: 100vh; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, FC } from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import Layout from './layout/layout'; 4 | import './App.css'; 5 | import { DarkTheme } from './ux/theme'; 6 | import { AppContext, ApplicationState, getDefaultState } from './models/applicationState'; 7 | import appReducer from './reducers'; 8 | import { TodoContext } from './components/todoContext'; 9 | import { initializeIcons } from '@fluentui/react/lib/Icons'; 10 | import { ThemeProvider } from '@fluentui/react'; 11 | import Telemetry from './components/telemetry'; 12 | 13 | initializeIcons(undefined, { disableWarnings: true }); 14 | 15 | const App: FC = () => { 16 | const defaultState: ApplicationState = getDefaultState(); 17 | const [applicationState, dispatch] = useReducer(appReducer, defaultState); 18 | const initialContext: AppContext = { state: applicationState, dispatch: dispatch } 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /src/web/src/actions/actionCreators.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Dispatch } from "react"; 3 | 4 | export interface Action { 5 | type: T 6 | } 7 | 8 | export interface AnyAction extends Action { 9 | [extraProps: string]: any 10 | } 11 | 12 | export interface ActionCreator { 13 | (...args: P): A 14 | } 15 | 16 | export interface ActionCreatorsMapObject { 17 | [key: string]: ActionCreator 18 | } 19 | 20 | export type ActionMethod = (dispatch: Dispatch) => Promise; 21 | 22 | export interface PayloadAction extends Action { 23 | payload: TPayload; 24 | } 25 | 26 | export function createAction>(type: TAction["type"]): () => Action { 27 | return () => ({ 28 | type, 29 | }); 30 | } 31 | 32 | export function createPayloadAction>(type: TAction["type"]): (payload: TAction["payload"]) => PayloadAction { 33 | return (payload: TAction["payload"]) => ({ 34 | type, 35 | payload, 36 | }); 37 | } 38 | 39 | export type BoundActionMethod = (...args: A[]) => Promise; 40 | export type BoundActionsMapObject = { [key: string]: BoundActionMethod } 41 | 42 | function bindActionCreator(actionCreator: ActionCreator, dispatch: Dispatch): BoundActionMethod { 43 | return async function (this: any, ...args: any[]) { 44 | const actionMethod = actionCreator.apply(this, args) as any as ActionMethod; 45 | return await actionMethod(dispatch); 46 | } 47 | } 48 | 49 | export function bindActionCreators( 50 | actionCreators: ActionCreator | ActionCreatorsMapObject, 51 | dispatch: Dispatch 52 | ): BoundActionsMapObject | BoundActionMethod { 53 | if (typeof actionCreators === 'function') { 54 | return bindActionCreator(actionCreators, dispatch) 55 | } 56 | 57 | if (typeof actionCreators !== 'object' || actionCreators === null) { 58 | throw new Error('bindActionCreators expected an object or a function, did you write "import ActionCreators from" instead of "import * as ActionCreators from"?') 59 | } 60 | 61 | const boundActionCreators: ActionCreatorsMapObject = {} 62 | for (const key in actionCreators) { 63 | const actionCreator = actionCreators[key] 64 | if (typeof actionCreator === 'function') { 65 | boundActionCreators[key] = bindActionCreator(actionCreator, dispatch) 66 | } 67 | } 68 | return boundActionCreators 69 | } -------------------------------------------------------------------------------- /src/web/src/actions/common.ts: -------------------------------------------------------------------------------- 1 | import * as itemActions from './itemActions'; 2 | import * as listActions from './listActions'; 3 | 4 | export enum ActionTypes { 5 | LOAD_TODO_LISTS = "LOAD_TODO_LISTS", 6 | LOAD_TODO_LIST = "LOAD_TODO_LIST", 7 | SELECT_TODO_LIST = "SELECT_TODO_LIST", 8 | SAVE_TODO_LIST = "SAVE_TODO_LIST", 9 | DELETE_TODO_LIST = "DELETE_TODO_LIST", 10 | LOAD_TODO_ITEMS = "LOAD_TODO_ITEMS", 11 | LOAD_TODO_ITEM = "LOAD_TODO_ITEM", 12 | SELECT_TODO_ITEM = "SELECT_TODO_ITEM", 13 | SAVE_TODO_ITEM = "SAVE_TODO_ITEM", 14 | DELETE_TODO_ITEM = "DELETE_TODO_ITEM" 15 | } 16 | 17 | export type TodoActions = 18 | itemActions.ListItemsAction | 19 | itemActions.SelectItemAction | 20 | itemActions.LoadItemAction | 21 | itemActions.SaveItemAction | 22 | itemActions.DeleteItemAction | 23 | listActions.ListListsAction | 24 | listActions.SelectListAction | 25 | listActions.LoadListAction | 26 | listActions.SaveListAction | 27 | listActions.DeleteListAction; -------------------------------------------------------------------------------- /src/web/src/actions/itemActions.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "react"; 2 | import { TodoItem } from "../models"; 3 | import { ItemService } from "../services/itemService"; 4 | import { ActionTypes } from "./common"; 5 | import config from "../config" 6 | import { ActionMethod, createPayloadAction, PayloadAction } from "./actionCreators"; 7 | 8 | export interface QueryOptions { 9 | [key: string]: RegExp | boolean 10 | } 11 | 12 | export interface ItemActions { 13 | list(listId: string, options?: QueryOptions): Promise 14 | select(item?: TodoItem): Promise 15 | load(listId: string, id: string): Promise 16 | save(listId: string, Item: TodoItem): Promise 17 | remove(listId: string, Item: TodoItem): Promise 18 | } 19 | 20 | export const list = (listId: string, options?: QueryOptions): ActionMethod => async (dispatch: Dispatch) => { 21 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 22 | const items = await itemService.getList(options); 23 | 24 | dispatch(listItemsAction(items)); 25 | 26 | return items; 27 | } 28 | 29 | export const select = (item?: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 30 | dispatch(selectItemAction(item)); 31 | 32 | return Promise.resolve(item); 33 | } 34 | 35 | export const load = (listId: string, id: string): ActionMethod => async (dispatch: Dispatch) => { 36 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 37 | const item = await itemService.get(id); 38 | 39 | dispatch(loadItemAction(item)); 40 | 41 | return item; 42 | } 43 | 44 | export const save = (listId: string, item: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 45 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 46 | const newItem = await itemService.save(item); 47 | 48 | dispatch(saveItemAction(newItem)); 49 | 50 | return newItem; 51 | } 52 | 53 | export const remove = (listId: string, item: TodoItem): ActionMethod => async (dispatch: Dispatch) => { 54 | const itemService = new ItemService(config.api.baseUrl, `/lists/${listId}/items`); 55 | if (item.id) { 56 | await itemService.delete(item.id); 57 | dispatch(deleteItemAction(item.id)); 58 | } 59 | } 60 | 61 | export interface ListItemsAction extends PayloadAction { 62 | type: ActionTypes.LOAD_TODO_ITEMS 63 | } 64 | 65 | export interface SelectItemAction extends PayloadAction { 66 | type: ActionTypes.SELECT_TODO_ITEM 67 | } 68 | 69 | export interface LoadItemAction extends PayloadAction { 70 | type: ActionTypes.LOAD_TODO_ITEM 71 | } 72 | 73 | export interface SaveItemAction extends PayloadAction { 74 | type: ActionTypes.SAVE_TODO_ITEM 75 | } 76 | 77 | export interface DeleteItemAction extends PayloadAction { 78 | type: ActionTypes.DELETE_TODO_ITEM 79 | } 80 | 81 | const listItemsAction = createPayloadAction(ActionTypes.LOAD_TODO_ITEMS); 82 | const selectItemAction = createPayloadAction(ActionTypes.SELECT_TODO_ITEM); 83 | const loadItemAction = createPayloadAction(ActionTypes.LOAD_TODO_ITEM); 84 | const saveItemAction = createPayloadAction(ActionTypes.SAVE_TODO_ITEM); 85 | const deleteItemAction = createPayloadAction(ActionTypes.DELETE_TODO_ITEM); 86 | -------------------------------------------------------------------------------- /src/web/src/actions/listActions.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from "react"; 2 | import { TodoList } from "../models"; 3 | import { ListService } from "../services/listService"; 4 | import { ActionTypes } from "./common"; 5 | import config from "../config" 6 | import { trackEvent } from "../services/telemetryService"; 7 | import { ActionMethod, createPayloadAction, PayloadAction } from "./actionCreators"; 8 | import { QueryOptions } from "./itemActions"; 9 | 10 | const listService = new ListService(config.api.baseUrl, '/lists'); 11 | 12 | export interface ListActions { 13 | list(options?: QueryOptions): Promise 14 | load(id: string): Promise 15 | select(list: TodoList): Promise 16 | save(list: TodoList): Promise 17 | remove(id: string): Promise 18 | } 19 | 20 | export const list = (options?: QueryOptions): ActionMethod => async (dispatch: Dispatch) => { 21 | const lists = await listService.getList(options); 22 | 23 | dispatch(listListsAction(lists)); 24 | 25 | return lists; 26 | } 27 | 28 | export const select = (list: TodoList): ActionMethod => (dispatch: Dispatch) => { 29 | dispatch(selectListAction(list)); 30 | 31 | return Promise.resolve(list); 32 | } 33 | 34 | export const load = (id: string): ActionMethod => async (dispatch: Dispatch) => { 35 | const list = await listService.get(id); 36 | 37 | dispatch(loadListAction(list)); 38 | 39 | return list; 40 | } 41 | 42 | export const save = (list: TodoList): ActionMethod => async (dispatch: Dispatch) => { 43 | const newList = await listService.save(list); 44 | 45 | dispatch(saveListAction(newList)); 46 | 47 | trackEvent(ActionTypes.SAVE_TODO_LIST.toString()); 48 | 49 | return newList; 50 | } 51 | 52 | export const remove = (id: string): ActionMethod => async (dispatch: Dispatch) => { 53 | await listService.delete(id); 54 | 55 | dispatch(deleteListAction(id)); 56 | } 57 | 58 | export interface ListListsAction extends PayloadAction { 59 | type: ActionTypes.LOAD_TODO_LISTS 60 | } 61 | 62 | export interface SelectListAction extends PayloadAction { 63 | type: ActionTypes.SELECT_TODO_LIST 64 | } 65 | 66 | export interface LoadListAction extends PayloadAction { 67 | type: ActionTypes.LOAD_TODO_LIST 68 | } 69 | 70 | export interface SaveListAction extends PayloadAction { 71 | type: ActionTypes.SAVE_TODO_LIST 72 | } 73 | 74 | export interface DeleteListAction extends PayloadAction { 75 | type: ActionTypes.DELETE_TODO_LIST 76 | } 77 | 78 | const listListsAction = createPayloadAction(ActionTypes.LOAD_TODO_LISTS); 79 | const selectListAction = createPayloadAction(ActionTypes.SELECT_TODO_LIST); 80 | const loadListAction = createPayloadAction(ActionTypes.LOAD_TODO_LIST); 81 | const saveListAction = createPayloadAction(ActionTypes.SAVE_TODO_LIST); 82 | const deleteListAction = createPayloadAction(ActionTypes.DELETE_TODO_LIST); 83 | -------------------------------------------------------------------------------- /src/web/src/components/telemetry.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement, useEffect, PropsWithChildren } from 'react'; 2 | import { TelemetryProvider } from './telemetryContext'; 3 | import { reactPlugin, getApplicationInsights } from '../services/telemetryService'; 4 | 5 | type TelemetryProps = PropsWithChildren; 6 | 7 | const Telemetry: FC = (props: TelemetryProps): ReactElement => { 8 | 9 | useEffect(() => { 10 | getApplicationInsights(); 11 | }, []); 12 | 13 | return ( 14 | 15 | {props.children} 16 | 17 | ); 18 | } 19 | 20 | export default Telemetry; 21 | -------------------------------------------------------------------------------- /src/web/src/components/telemetryContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { reactPlugin } from '../services/telemetryService'; 3 | 4 | const TelemetryContext = createContext(reactPlugin); 5 | 6 | export const TelemetryProvider = TelemetryContext.Provider; 7 | export const TelemetryConsumer = TelemetryContext.Consumer; 8 | export default TelemetryContext; 9 | -------------------------------------------------------------------------------- /src/web/src/components/telemetryWithAppInsights.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, ComponentClass } from 'react'; 2 | import { reactPlugin } from '../services/telemetryService'; 3 | import { withAITracking } from '@microsoft/applicationinsights-react-js'; 4 | 5 | 6 | const withApplicationInsights = (component: ComponentType, componentName: string): ComponentClass, unknown> => withAITracking(reactPlugin, component, componentName); 7 | 8 | export default withApplicationInsights; 9 | -------------------------------------------------------------------------------- /src/web/src/components/todoContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { AppContext, getDefaultState } from "../models/applicationState"; 3 | 4 | const initialState = getDefaultState(); 5 | const dispatch = () => { return }; 6 | 7 | export const TodoContext = createContext({ state: initialState, dispatch: dispatch }); -------------------------------------------------------------------------------- /src/web/src/components/todoItemDetailPane.tsx: -------------------------------------------------------------------------------- 1 | import { Text, DatePicker, Stack, TextField, PrimaryButton, DefaultButton, Dropdown, IDropdownOption, FontIcon } from '@fluentui/react'; 2 | import { useEffect, useState, FC, ReactElement, MouseEvent, FormEvent } from 'react'; 3 | import { TodoItem, TodoItemState } from '../models'; 4 | import { stackGaps, stackItemMargin, stackItemPadding, titleStackStyles } from '../ux/styles'; 5 | 6 | interface TodoItemDetailPaneProps { 7 | item?: TodoItem; 8 | onEdit: (item: TodoItem) => void 9 | onCancel: () => void 10 | } 11 | 12 | export const TodoItemDetailPane: FC = (props: TodoItemDetailPaneProps): ReactElement => { 13 | const [name, setName] = useState(props.item?.name || ''); 14 | const [description, setDescription] = useState(props.item?.description); 15 | const [dueDate, setDueDate] = useState(props.item?.dueDate); 16 | const [state, setState] = useState(props.item?.state || TodoItemState.Todo); 17 | 18 | useEffect(() => { 19 | setName(props.item?.name || ''); 20 | setDescription(props.item?.description); 21 | setDueDate(props.item?.dueDate ? new Date(props.item?.dueDate) : undefined); 22 | setState(props.item?.state || TodoItemState.Todo); 23 | }, [props.item]); 24 | 25 | const saveTodoItem = (evt: MouseEvent) => { 26 | evt.preventDefault(); 27 | 28 | if (!props.item?.id) { 29 | return; 30 | } 31 | 32 | const todoItem: TodoItem = { 33 | id: props.item.id, 34 | listId: props.item.listId, 35 | name: name, 36 | description: description, 37 | dueDate: dueDate, 38 | state: state, 39 | }; 40 | 41 | props.onEdit(todoItem); 42 | }; 43 | 44 | const cancelEdit = () => { 45 | props.onCancel(); 46 | } 47 | 48 | const onStateChange = (_evt: FormEvent, value?: IDropdownOption) => { 49 | if (value) { 50 | setState(value.key as TodoItemState); 51 | } 52 | } 53 | 54 | const onDueDateChange = (date: Date | null | undefined) => { 55 | setDueDate(date || undefined); 56 | } 57 | 58 | const todoStateOptions: IDropdownOption[] = [ 59 | { key: TodoItemState.Todo, text: 'To Do' }, 60 | { key: TodoItemState.InProgress, text: 'In Progress' }, 61 | { key: TodoItemState.Done, text: 'Done' }, 62 | ]; 63 | 64 | return ( 65 | 66 | {props.item && 67 | <> 68 | 69 | {name} 70 | {description} 71 | 72 | 73 | setName(value || '')} /> 74 | setDescription(value)} /> 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | } 86 | {!props.item && 87 | 88 | 89 | Select an item to edit 90 | } 91 | 92 | ); 93 | } 94 | 95 | export default TodoItemDetailPane; -------------------------------------------------------------------------------- /src/web/src/components/todoItemListPane.tsx: -------------------------------------------------------------------------------- 1 | import { CommandBar, DetailsList, DetailsListLayoutMode, IStackStyles, Selection, Label, Spinner, SpinnerSize, Stack, IIconProps, SearchBox, Text, IGroup, IColumn, MarqueeSelection, FontIcon, IObjectWithKey, CheckboxVisibility, IDetailsGroupRenderProps, getTheme } from '@fluentui/react'; 2 | import { ReactElement, useEffect, useState, FormEvent, FC } from 'react'; 3 | import { useNavigate } from 'react-router'; 4 | import { TodoItem, TodoItemState, TodoList } from '../models'; 5 | import { stackItemPadding } from '../ux/styles'; 6 | 7 | interface TodoItemListPaneProps { 8 | list?: TodoList 9 | items?: TodoItem[] 10 | selectedItem?: TodoItem; 11 | disabled: boolean 12 | onCreated: (item: TodoItem) => void 13 | onDelete: (item: TodoItem) => void 14 | onComplete: (item: TodoItem) => void 15 | onSelect: (item?: TodoItem) => void 16 | } 17 | 18 | interface TodoDisplayItem extends IObjectWithKey { 19 | id?: string 20 | listId: string 21 | name: string 22 | state: TodoItemState 23 | description?: string 24 | dueDate: Date | string 25 | completedDate: Date | string 26 | data: TodoItem 27 | createdDate?: Date 28 | updatedDate?: Date 29 | } 30 | 31 | const addIconProps: IIconProps = { 32 | iconName: 'Add', 33 | styles: { 34 | root: { 35 | } 36 | } 37 | }; 38 | 39 | const createListItems = (items: TodoItem[]): TodoDisplayItem[] => { 40 | return items.map(item => ({ 41 | ...item, 42 | key: item.id, 43 | dueDate: item.dueDate ? new Date(item.dueDate).toDateString() : 'None', 44 | completedDate: item.completedDate ? new Date(item.completedDate).toDateString() : 'N/A', 45 | data: item 46 | })); 47 | }; 48 | 49 | const stackStyles: IStackStyles = { 50 | root: { 51 | alignItems: 'center' 52 | } 53 | } 54 | 55 | const TodoItemListPane: FC = (props: TodoItemListPaneProps): ReactElement => { 56 | const theme = getTheme(); 57 | const navigate = useNavigate(); 58 | const [newItemName, setNewItemName] = useState(''); 59 | const [items, setItems] = useState(createListItems(props.items || [])); 60 | const [selectedItems, setSelectedItems] = useState([]); 61 | const [isDoneCategoryCollapsed, setIsDoneCategoryCollapsed] = useState(true); 62 | 63 | // eslint-disable-next-line react-hooks/exhaustive-deps 64 | const selection = new Selection({ 65 | onSelectionChanged: () => { 66 | const selectedItems = selection.getSelection().map(item => (item as TodoDisplayItem).data); 67 | setSelectedItems(selectedItems); 68 | } 69 | }); 70 | 71 | // Handle list changed 72 | useEffect(() => { 73 | setIsDoneCategoryCollapsed(true); 74 | setSelectedItems([]); 75 | }, [props.list]); 76 | 77 | // Handle items changed 78 | useEffect(() => { 79 | const sortedItems = (props.items || []).sort((a, b) => { 80 | if (a.state === b.state) { 81 | return a.name < b.name ? -1 : 1; 82 | } 83 | 84 | return a.state < b.state ? -1 : 1; 85 | }) 86 | setItems(createListItems(sortedItems || [])); 87 | }, [props.items]); 88 | 89 | // Handle selected item changed 90 | useEffect(() => { 91 | if (items.length > 0 && props.selectedItem?.id) { 92 | selection.setKeySelected(props.selectedItem.id, true, true); 93 | } 94 | 95 | const doneItems = selectedItems.filter(i => i.state === TodoItemState.Done); 96 | if (doneItems.length > 0) { 97 | setIsDoneCategoryCollapsed(false); 98 | } 99 | 100 | }, [items.length, props.selectedItem, selectedItems, selection]) 101 | 102 | const groups: IGroup[] = [ 103 | { 104 | key: TodoItemState.Todo, 105 | name: 'Todo', 106 | count: items.filter(i => i.state === TodoItemState.Todo).length, 107 | startIndex: items.findIndex(i => i.state === TodoItemState.Todo), 108 | }, 109 | { 110 | key: TodoItemState.InProgress, 111 | name: 'In Progress', 112 | count: items.filter(i => i.state === TodoItemState.InProgress).length, 113 | startIndex: items.findIndex(i => i.state === TodoItemState.InProgress) 114 | }, 115 | { 116 | key: TodoItemState.Done, 117 | name: 'Done', 118 | count: items.filter(i => i.state === TodoItemState.Done).length, 119 | startIndex: items.findIndex(i => i.state === TodoItemState.Done), 120 | isCollapsed: isDoneCategoryCollapsed 121 | }, 122 | ] 123 | 124 | const onFormSubmit = (evt: FormEvent) => { 125 | evt.preventDefault(); 126 | 127 | if (newItemName && props.onCreated) { 128 | const item: TodoItem = { 129 | name: newItemName, 130 | listId: props.list?.id || '', 131 | state: TodoItemState.Todo, 132 | } 133 | props.onCreated(item); 134 | setNewItemName(''); 135 | } 136 | } 137 | 138 | const onNewItemChanged = (_evt?: FormEvent, value?: string) => { 139 | setNewItemName(value || ''); 140 | } 141 | 142 | const selectItem = (item: TodoDisplayItem) => { 143 | navigate(`/lists/${item.data.listId}/items/${item.data.id}`); 144 | } 145 | 146 | const completeItems = () => { 147 | selectedItems.map(item => props.onComplete(item)); 148 | } 149 | 150 | const deleteItems = () => { 151 | selectedItems.map(item => props.onDelete(item)); 152 | } 153 | 154 | const columns: IColumn[] = [ 155 | { key: 'name', name: 'Name', fieldName: 'name', minWidth: 100 }, 156 | { key: 'dueDate', name: 'Due', fieldName: 'dueDate', minWidth: 100 }, 157 | { key: 'completedDate', name: 'Completed', fieldName: 'completedDate', minWidth: 100 }, 158 | ]; 159 | 160 | const groupRenderProps: IDetailsGroupRenderProps = { 161 | headerProps: { 162 | styles: { 163 | groupHeaderContainer: { 164 | backgroundColor: theme.palette.neutralPrimary 165 | } 166 | } 167 | } 168 | } 169 | 170 | const renderItemColumn = (item: TodoDisplayItem, _index?: number, column?: IColumn) => { 171 | const fieldContent = item[column?.fieldName as keyof TodoDisplayItem] as string; 172 | 173 | switch (column?.key) { 174 | case "name": 175 | return ( 176 | <> 177 | {item.name} 178 | {item.description && 179 | <> 180 | 181 | {item.description} 182 | 183 | } 184 | 185 | ); 186 | default: 187 | return ({fieldContent}) 188 | } 189 | } 190 | 191 | return ( 192 | 193 | 194 |
195 | 196 | 197 | 198 | 199 | 200 | { completeItems() } 208 | }, 209 | { 210 | key: 'delete', 211 | text: 'Delete', 212 | disabled: props.disabled, 213 | iconProps: { iconName: 'Delete' }, 214 | onClick: () => { deleteItems() } 215 | } 216 | ]} 217 | ariaLabel="Todo actions" /> 218 | 219 | 220 |
221 |
222 | {items.length > 0 && 223 | 224 | 225 | 240 | 241 | 242 | } 243 | {!props.items && 244 | 245 | 246 | 247 | 248 | } 249 | {props.items && items.length === 0 && 250 | 251 | This list is empty. 252 | 253 | } 254 |
255 | ); 256 | }; 257 | 258 | export default TodoItemListPane; -------------------------------------------------------------------------------- /src/web/src/components/todoListMenu.tsx: -------------------------------------------------------------------------------- 1 | import { IIconProps, INavLink, INavLinkGroup, Nav, Stack, TextField } from '@fluentui/react'; 2 | import { FC, ReactElement, useState, FormEvent, MouseEvent } from 'react'; 3 | import { useNavigate } from 'react-router'; 4 | import { TodoList } from '../models/todoList'; 5 | import { stackItemPadding } from '../ux/styles'; 6 | 7 | interface TodoListMenuProps { 8 | selectedList?: TodoList 9 | lists?: TodoList[] 10 | onCreate: (list: TodoList) => void 11 | } 12 | 13 | const iconProps: IIconProps = { 14 | iconName: 'AddToShoppingList' 15 | } 16 | 17 | const TodoListMenu: FC = (props: TodoListMenuProps): ReactElement => { 18 | const navigate = useNavigate(); 19 | const [newListName, setNewListName] = useState(''); 20 | 21 | const onNavLinkClick = (evt?: MouseEvent, item?: INavLink) => { 22 | evt?.preventDefault(); 23 | 24 | if (!item) { 25 | return; 26 | } 27 | 28 | navigate(`/lists/${item.key}`); 29 | } 30 | 31 | const createNavGroups = (lists: TodoList[]): INavLinkGroup[] => { 32 | const links = lists.map(list => ({ 33 | key: list.id, 34 | name: list.name, 35 | url: `/lists/${list.id}`, 36 | links: [], 37 | isExpanded: props.selectedList ? list.id === props.selectedList.id : false 38 | })); 39 | 40 | return [{ 41 | links: links 42 | }] 43 | } 44 | 45 | const onNewListNameChange = (_evt: FormEvent, value?: string) => { 46 | setNewListName(value || ''); 47 | } 48 | 49 | const onFormSubmit = async (evt: FormEvent) => { 50 | evt.preventDefault(); 51 | 52 | if (newListName) { 53 | const list: TodoList = { 54 | name: newListName 55 | }; 56 | 57 | props.onCreate(list); 58 | setNewListName(''); 59 | } 60 | } 61 | 62 | return ( 63 | 64 | 65 |