├── .azdo └── pipelines │ └── azure-dev.yml ├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github └── workflows │ └── azure-dev.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── NOTICE.txt ├── OPTIONAL_FEATURES.md ├── README.md ├── assets ├── resources-with-apim.png ├── resources.png ├── urls.png └── web.png ├── azure.yaml ├── infra ├── abbreviations.json ├── app │ ├── api-appservice-avm.bicep │ └── db-avm.bicep ├── main.bicep └── main.parameters.json ├── openapi.yaml ├── src ├── api │ ├── .gitignore │ ├── .vscode │ │ ├── launch.json │ │ └── settings.json │ ├── Dockerfile │ ├── README.md │ ├── catchAllFunction │ │ ├── __init__.py │ │ └── function.json │ ├── host.json │ ├── local.settings.json │ ├── 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 │ ├── staticwebapp.config.json │ ├── 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 | AZD_INITIAL_ENVIRONMENT_CONFIG: $(secrets.AZD_INITIAL_ENVIRONMENT_CONFIG) 45 | 46 | - task: AzureCLI@2 47 | displayName: Deploy Application 48 | inputs: 49 | azureSubscription: azconnection 50 | scriptType: bash 51 | scriptLocation: inlineScript 52 | inlineScript: | 53 | azd deploy --no-prompt 54 | env: 55 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 56 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 57 | AZURE_LOCATION: $(AZURE_LOCATION) 58 | -------------------------------------------------------------------------------- /.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/docker-in-docker:2": { 6 | }, 7 | "ghcr.io/devcontainers/features/node:1": { 8 | "version": "18", 9 | "nodeGypDependencies": false 10 | }, 11 | "ghcr.io/azure/azure-dev/azd:latest": {} 12 | }, 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "GitHub.vscode-github-actions", 17 | "ms-azuretools.azure-dev", 18 | "ms-azuretools.vscode-azurefunctions", 19 | "ms-azuretools.vscode-bicep", 20 | "ms-azuretools.vscode-docker", 21 | "ms-python.python", 22 | "ms-vscode.vscode-node-azure-pack" 23 | ] 24 | } 25 | }, 26 | "forwardPorts": [ 27 | 3000, 28 | 3100 29 | ], 30 | "postCreateCommand": "echo 'Installing functions-core-tools:' && tmp_folder=$(mktemp -d) && install_folder=/opt/microsoft/azure-functions-core-tools && cd $tmp_folder && wget -q \"https://github.com/Azure/azure-functions-core-tools/releases/download/4.0.5455/Azure.Functions.Cli.linux-x64.4.0.5455.zip\" && echo ' - extracting files.' && unzip -q Azure.Functions.Cli.linux-x64.4.0.5455.zip && rm Azure.Functions.Cli.linux-x64.4.0.5455.zip && chmod +x func && chmod +x gozip && sudo mkdir -p $install_folder && sudo rsync -av $tmp_folder/ $install_folder && rm -rf $tmp_folder && echo ' - export func.' && sudo ln -fs $install_folder/func /usr/local/bin/func", 31 | "remoteUser": "vscode", 32 | "hostRequirements": { 33 | "memory": "8gb" 34 | } 35 | } -------------------------------------------------------------------------------- /.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` 12 | 13 | # Set up permissions for deploying with secretless Azure federated credentials 14 | # https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#set-up-azure-login-with-openid-connect-authentication 15 | permissions: 16 | id-token: write 17 | contents: read 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | env: 23 | AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} 24 | AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} 25 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 26 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Install azd 32 | uses: Azure/setup-azd@v2 33 | 34 | - name: Install Azure Function Core Tools 35 | run: | 36 | sudo apt-get update 37 | sudo apt-get install azure-functions-core-tools-4 38 | 39 | - name: Log in with Azure (Federated Credentials) 40 | if: ${{ env.AZURE_CLIENT_ID != '' }} 41 | run: | 42 | azd auth login ` 43 | --client-id "$Env:AZURE_CLIENT_ID" ` 44 | --federated-credential-provider "github" ` 45 | --tenant-id "$Env:AZURE_TENANT_ID" 46 | shell: pwsh 47 | 48 | - name: Log in with Azure (Client Credentials) 49 | if: ${{ env.AZURE_CREDENTIALS != '' }} 50 | run: | 51 | $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable; 52 | Write-Host "::add-mask::$($info.clientSecret)" 53 | 54 | azd auth login ` 55 | --client-id "$($info.clientId)" ` 56 | --client-secret "$($info.clientSecret)" ` 57 | --tenant-id "$($info.tenantId)" 58 | shell: pwsh 59 | env: 60 | AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} 61 | 62 | - name: Provision Infrastructure 63 | run: azd provision --no-prompt 64 | env: 65 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 66 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 67 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 68 | AZD_INITIAL_ENVIRONMENT_CONFIG: ${{ secrets.AZD_INITIAL_ENVIRONMENT_CONFIG }} 69 | 70 | - name: Deploy Application 71 | run: azd deploy --no-prompt 72 | env: 73 | AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }} 74 | AZURE_LOCATION: ${{ vars.AZURE_LOCATION }} 75 | AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} 76 | -------------------------------------------------------------------------------- /.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/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "./src/api", 3 | "azureFunctions.scmDoBuildDuringDeployment": true, 4 | "azureFunctions.pythonVenv": "api_env", 5 | "azureFunctions.projectLanguage": "Python", 6 | "azureFunctions.projectRuntime": "~4", 7 | "debug.internalConsoleOptions": "neverOpen" 8 | } -------------------------------------------------------------------------------- /.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 | "label": "Start API", 41 | "type": "dotenv", 42 | "targetTasks": [ 43 | "Restore API", 44 | "Start Functions" 45 | ], 46 | "file": "${input:dotEnvFilePath}" 47 | }, 48 | { 49 | "label": "Start Functions", 50 | "type": "func", 51 | "command": "host start --port 3100 --cors '*'", 52 | "problemMatcher": "$func-python-watch", 53 | "isBackground": true, 54 | "dependsOn": "Restore API", 55 | "options": { 56 | "cwd": "${workspaceFolder}/src/api" 57 | } 58 | }, 59 | { 60 | "label": "Restore API", 61 | "type": "shell", 62 | "command": "azd restore api", 63 | "presentation": { 64 | "reveal": "silent" 65 | }, 66 | "problemMatcher": [] 67 | }, 68 | { 69 | "label": "Start API and Web", 70 | "dependsOn":[ 71 | "Start API", 72 | "Start Web" 73 | ], 74 | "problemMatcher": [] 75 | } 76 | ], 77 | 78 | "inputs": [ 79 | { 80 | "id": "dotEnvFilePath", 81 | "type": "command", 82 | "command": "azure-dev.commands.getDotEnvFilePath" 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /OPTIONAL_FEATURES.md: -------------------------------------------------------------------------------- 1 | ### Enable Additional Features 2 | 3 | #### Enable [Azure API Management](https://learn.microsoft.com/azure/api-management/) 4 | 5 | This template is prepared to use Azure API Management (aka APIM) for backend API protection and observability. APIM supports the complete API lifecycle and abstract backend complexity from API consumers. 6 | 7 | To use APIM on this template you just need to set the environment variable with the following command: 8 | 9 | ```bash 10 | azd env set USE_APIM true 11 | ``` 12 | And then execute `azd up` to provision and deploy. No worries if you already did `azd up`! You can set the `USE_APIM` environment variable at anytime and then just repeat the `azd up` command to run the incremental deployment. 13 | 14 | Here's the high level architecture diagram when APIM is used: 15 | 16 | !["Application architecture diagram with APIM"](assets/resources-with-apim.png) 17 | 18 | The frontend will be configured to make API requests through APIM instead of calling the backend directly, so that the following flow gets executed: 19 | 20 | 1. APIM receives the frontend request, applies the configured policy to enable CORS, validates content and limits concurrency. Follow this [guide](https://learn.microsoft.com/azure/api-management/api-management-howto-policies) to understand how to customize the policy. 21 | 1. If there are no errors, the request is forwarded to the backend and then the backend response is sent back to the frontend. 22 | 1. APIM emits logs, metrics, and traces for monitoring, reporting, and troubleshooting on every execution. Follow this [guide](https://learn.microsoft.com/azure/api-management/api-management-howto-use-azure-monitor) to visualize, query, and take actions on the metrics or logs coming from APIM. 23 | 24 | > NOTE: 25 | > 26 | > By default, this template uses the Consumption tier that is a lightweight and serverless version of API Management service, billed per execution. Please check the [pricing page](https://azure.microsoft.com/pricing/details/api-management/) for more details. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | languages: 4 | - azdeveloper 5 | - python 6 | - bicep 7 | - typescript 8 | - html 9 | products: 10 | - azure 11 | - azure-cosmos-db 12 | - azure-functions 13 | - azure-monitor 14 | - azure-pipelines 15 | urlFragment: todo-python-mongo-swa-func 16 | name: Static React Web App + Functions with Python API and MongoDB 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 | # Static React Web App + Functions with Python API and MongoDB 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-swa-func) 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-swa-func) 25 | 26 | A blueprint for getting a React web app with Python (FastAPI) API and a MongoDB database running on Azure. The frontend, currently a ToDo application, is designed as a placeholder that can easily be removed and replaced with your own frontend code. This architecture is for hosting static web apps with serverless logic and functionality. 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 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally, or open the project in Github Codespaces or [VS Code](https://code.visualstudio.com/) with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) where they will be installed automatically. 38 | 39 | - [Azure Developer CLI](https://aka.ms/azd-install) 40 | - [Azure Functions Core Tools (4+)](https://docs.microsoft.com/azure/azure-functions/functions-run-local) 41 | - [Python (3.10+)](https://www.python.org/downloads/) - for the API backend 42 | 43 | ### Quickstart 44 | 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-swa-func`). 45 | 46 | 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: 47 | 48 | ```bash 49 | # Log in to azd. Only required once per-install. 50 | azd auth login 51 | 52 | # First-time project setup. Initialize a project in the current directory, using this template. 53 | azd init --template Azure-Samples/todo-python-mongo-swa-func 54 | 55 | # Provision and deploy to Azure 56 | azd up 57 | ``` 58 | 59 | ### Application Architecture 60 | 61 | This application utilizes the following Azure resources: 62 | 63 | - [**Azure Static Web Apps**](https://docs.microsoft.com/azure/static-web-apps/) to host the Web frontend 64 | - [**Azure Function Apps**](https://docs.microsoft.com/azure/azure-functions/) to host the API backend 65 | - [**Azure Cosmos DB API for MongoDB**](https://docs.microsoft.com/azure/cosmos-db/mongodb/mongodb-introduction) for storage 66 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 67 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 68 | 69 | 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. 70 | 71 | !["Application architecture diagram"](assets/resources.png) 72 | 73 | ### Cost of provisioning and deploying this template 74 | This template provisions resources to an Azure subscription that you will select upon provisioning them. Refer to the [Pricing calculator for Microsoft Azure](https://azure.microsoft.com/pricing/calculator/) to estimate the cost you might incur when this template is running on Azure and, if needed, update the included Azure resource definitions found in `infra/main.bicep` to suit your needs. 75 | 76 | ### Application Code 77 | 78 | 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). 79 | 80 | ### Next Steps 81 | 82 | 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. 83 | 84 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 85 | 86 | - [`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. 87 | 88 | - [`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) 89 | 90 | - [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 91 | 92 | - [`azd down`](https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 93 | 94 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 95 | 96 | ### Additional `azd` commands 97 | 98 | 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. 99 | 100 | ## Security 101 | 102 | ### Roles 103 | 104 | 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). 105 | 106 | ### Key Vault 107 | 108 | 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. 109 | 110 | ## Reporting Issues and Feedback 111 | 112 | 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. 113 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-swa-func/dceed84e6fa3698062c6b8618ef689c239343639/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-swa-func/dceed84e6fa3698062c6b8618ef689c239343639/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-swa-func/dceed84e6fa3698062c6b8618ef689c239343639/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-swa-func/dceed84e6fa3698062c6b8618ef689c239343639/assets/web.png -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: todo-python-mongo-swa-func 4 | metadata: 5 | template: todo-python-mongo-swa-func@0.0.1-beta 6 | workflows: 7 | up: 8 | steps: 9 | - azd: provision 10 | - azd: deploy --all 11 | services: 12 | web: 13 | project: ./src/web 14 | dist: dist 15 | language: js 16 | host: staticwebapp 17 | hooks: 18 | # Creates a temporary `.env.local` file for the build command. Vite will automatically use it during build. 19 | # The expected/required values are mapped to the infrastructure outputs. 20 | # .env.local is ignored by git, so it will not be committed if, for any reason, if deployment fails. 21 | # see: https://vitejs.dev/guide/env-and-mode 22 | # Note: Notice that dotenv must be a project dependency for this to work. See package.json. 23 | prepackage: 24 | windows: 25 | shell: pwsh 26 | run: 'echo "VITE_API_BASE_URL=""$env:API_BASE_URL""" > .env.local ; echo "VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=""$env:APPLICATIONINSIGHTS_CONNECTION_STRING""" >> .env.local' 27 | posix: 28 | shell: sh 29 | run: 'echo VITE_API_BASE_URL=\"$API_BASE_URL\" > .env.local && echo VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" >> .env.local' 30 | postdeploy: 31 | windows: 32 | shell: pwsh 33 | run: 'rm .env.local' 34 | posix: 35 | shell: sh 36 | run: 'rm .env.local' 37 | api: 38 | project: ./src/api 39 | language: py 40 | host: function 41 | -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationStores": "appcs-", 5 | "appManagedEnvironments": "cae-", 6 | "appContainerApps": "ca-", 7 | "authorizationPolicyDefinitions": "policy-", 8 | "automationAutomationAccounts": "aa-", 9 | "blueprintBlueprints": "bp-", 10 | "blueprintBlueprintsArtifacts": "bpa-", 11 | "cacheRedis": "redis-", 12 | "cdnProfiles": "cdnp-", 13 | "cdnProfilesEndpoints": "cdne-", 14 | "cognitiveServicesAccounts": "cog-", 15 | "cognitiveServicesFormRecognizer": "cog-fr-", 16 | "cognitiveServicesTextAnalytics": "cog-ta-", 17 | "cognitiveServicesSpeech": "cog-sp-", 18 | "computeAvailabilitySets": "avail-", 19 | "computeCloudServices": "cld-", 20 | "computeDiskEncryptionSets": "des", 21 | "computeDisks": "disk", 22 | "computeDisksOs": "osdisk", 23 | "computeGalleries": "gal", 24 | "computeSnapshots": "snap-", 25 | "computeVirtualMachines": "vm", 26 | "computeVirtualMachineScaleSets": "vmss-", 27 | "containerInstanceContainerGroups": "ci", 28 | "containerRegistryRegistries": "cr", 29 | "containerServiceManagedClusters": "aks-", 30 | "databricksWorkspaces": "dbw-", 31 | "dataFactoryFactories": "adf-", 32 | "dataLakeAnalyticsAccounts": "dla", 33 | "dataLakeStoreAccounts": "dls", 34 | "dataMigrationServices": "dms-", 35 | "dBforMySQLServers": "mysql-", 36 | "dBforPostgreSQLServers": "psql-", 37 | "devicesIotHubs": "iot-", 38 | "devicesProvisioningServices": "provs-", 39 | "devicesProvisioningServicesCertificates": "pcert-", 40 | "documentDBDatabaseAccounts": "cosmos-", 41 | "eventGridDomains": "evgd-", 42 | "eventGridDomainsTopics": "evgt-", 43 | "eventGridEventSubscriptions": "evgs-", 44 | "eventHubNamespaces": "evhns-", 45 | "eventHubNamespacesEventHubs": "evh-", 46 | "hdInsightClustersHadoop": "hadoop-", 47 | "hdInsightClustersHbase": "hbase-", 48 | "hdInsightClustersKafka": "kafka-", 49 | "hdInsightClustersMl": "mls-", 50 | "hdInsightClustersSpark": "spark-", 51 | "hdInsightClustersStorm": "storm-", 52 | "hybridComputeMachines": "arcs-", 53 | "insightsActionGroups": "ag-", 54 | "insightsComponents": "appi-", 55 | "keyVaultVaults": "kv-", 56 | "kubernetesConnectedClusters": "arck", 57 | "kustoClusters": "dec", 58 | "kustoClustersDatabases": "dedb", 59 | "loadTesting": "lt-", 60 | "logicIntegrationAccounts": "ia-", 61 | "logicWorkflows": "logic-", 62 | "machineLearningServicesWorkspaces": "mlw-", 63 | "managedIdentityUserAssignedIdentities": "id-", 64 | "managementManagementGroups": "mg-", 65 | "migrateAssessmentProjects": "migr-", 66 | "networkApplicationGateways": "agw-", 67 | "networkApplicationSecurityGroups": "asg-", 68 | "networkAzureFirewalls": "afw-", 69 | "networkBastionHosts": "bas-", 70 | "networkConnections": "con-", 71 | "networkDnsZones": "dnsz-", 72 | "networkExpressRouteCircuits": "erc-", 73 | "networkFirewallPolicies": "afwp-", 74 | "networkFirewallPoliciesWebApplication": "waf", 75 | "networkFirewallPoliciesRuleGroups": "wafrg", 76 | "networkFrontDoors": "fd-", 77 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 78 | "networkLoadBalancersExternal": "lbe-", 79 | "networkLoadBalancersInternal": "lbi-", 80 | "networkLoadBalancersInboundNatRules": "rule-", 81 | "networkLocalNetworkGateways": "lgw-", 82 | "networkNatGateways": "ng-", 83 | "networkNetworkInterfaces": "nic-", 84 | "networkNetworkSecurityGroups": "nsg-", 85 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 86 | "networkNetworkWatchers": "nw-", 87 | "networkPrivateDnsZones": "pdnsz-", 88 | "networkPrivateLinkServices": "pl-", 89 | "networkPublicIPAddresses": "pip-", 90 | "networkPublicIPPrefixes": "ippre-", 91 | "networkRouteFilters": "rf-", 92 | "networkRouteTables": "rt-", 93 | "networkRouteTablesRoutes": "udr-", 94 | "networkTrafficManagerProfiles": "traf-", 95 | "networkVirtualNetworkGateways": "vgw-", 96 | "networkVirtualNetworks": "vnet-", 97 | "networkVirtualNetworksSubnets": "snet-", 98 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 99 | "networkVirtualWans": "vwan-", 100 | "networkVpnGateways": "vpng-", 101 | "networkVpnGatewaysVpnConnections": "vcn-", 102 | "networkVpnGatewaysVpnSites": "vst-", 103 | "notificationHubsNamespaces": "ntfns-", 104 | "notificationHubsNamespacesNotificationHubs": "ntf-", 105 | "operationalInsightsWorkspaces": "log-", 106 | "portalDashboards": "dash-", 107 | "powerBIDedicatedCapacities": "pbi-", 108 | "purviewAccounts": "pview-", 109 | "recoveryServicesVaults": "rsv-", 110 | "resourcesResourceGroups": "rg-", 111 | "searchSearchServices": "srch-", 112 | "serviceBusNamespaces": "sb-", 113 | "serviceBusNamespacesQueues": "sbq-", 114 | "serviceBusNamespacesTopics": "sbt-", 115 | "serviceEndPointPolicies": "se-", 116 | "serviceFabricClusters": "sf-", 117 | "signalRServiceSignalR": "sigr", 118 | "sqlManagedInstances": "sqlmi-", 119 | "sqlServers": "sql-", 120 | "sqlServersDataWarehouse": "sqldw-", 121 | "sqlServersDatabases": "sqldb-", 122 | "sqlServersDatabasesStretch": "sqlstrdb-", 123 | "storageStorageAccounts": "st", 124 | "storageStorageAccountsVm": "stvm", 125 | "storSimpleManagers": "ssimp", 126 | "streamAnalyticsCluster": "asa-", 127 | "synapseWorkspaces": "syn", 128 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 129 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 130 | "synapseWorkspacesSqlPoolsSpark": "synsp", 131 | "timeSeriesInsightsEnvironments": "tsi-", 132 | "webServerFarms": "plan-", 133 | "webSitesAppService": "app-", 134 | "webSitesAppServiceEnvironment": "ase-", 135 | "webSitesFunctions": "func-", 136 | "webStaticSites": "stapp-" 137 | } 138 | -------------------------------------------------------------------------------- /infra/app/api-appservice-avm.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param allowedOrigins array = [] 6 | param appCommandLine string? 7 | param appInsightResourceId string 8 | param appServicePlanId string 9 | @secure() 10 | param appSettings object = {} 11 | param siteConfig object = {} 12 | param serviceName string = 'api' 13 | 14 | @description('Required. Type of site to deploy.') 15 | param kind string 16 | 17 | @description('Optional. If client affinity is enabled.') 18 | param clientAffinityEnabled bool = true 19 | 20 | @description('Optional. Required if app of kind functionapp. Resource ID of the storage account to manage triggers and logging function executions.') 21 | param storageAccountResourceId string? 22 | 23 | module api 'br/public:avm/res/web/site:0.6.0' = { 24 | name: '${name}-app-module' 25 | params: { 26 | kind: kind 27 | name: name 28 | serverFarmResourceId: appServicePlanId 29 | tags: union(tags, { 'azd-service-name': serviceName }) 30 | location: location 31 | appInsightResourceId: appInsightResourceId 32 | clientAffinityEnabled: clientAffinityEnabled 33 | storageAccountResourceId: storageAccountResourceId 34 | managedIdentities: { 35 | systemAssigned: true 36 | } 37 | siteConfig: union(siteConfig, { 38 | cors: { 39 | allowedOrigins: union(['https://portal.azure.com', 'https://ms.portal.azure.com'], allowedOrigins) 40 | } 41 | appCommandLine: appCommandLine 42 | }) 43 | appSettingsKeyValuePairs: union( 44 | appSettings, 45 | { ENABLE_ORYX_BUILD: true, ApplicationInsightsAgent_EXTENSION_VERSION: contains(kind, 'linux') ? '~3' : '~2' } 46 | ) 47 | logsConfiguration: { 48 | applicationLogs: { fileSystem: { level: 'Verbose' } } 49 | detailedErrorMessages: { enabled: true } 50 | failedRequestsTracing: { enabled: true } 51 | httpLogs: { fileSystem: { enabled: true, retentionInDays: 1, retentionInMb: 35 } } 52 | } 53 | } 54 | } 55 | 56 | output SERVICE_API_IDENTITY_PRINCIPAL_ID string = api.outputs.systemAssignedMIPrincipalId 57 | output SERVICE_API_NAME string = api.outputs.name 58 | output SERVICE_API_URI string = 'https://${api.outputs.defaultHostname}' 59 | -------------------------------------------------------------------------------- /infra/app/db-avm.bicep: -------------------------------------------------------------------------------- 1 | param accountName string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param cosmosDatabaseName string = '' 5 | param keyVaultResourceId string 6 | param connectionStringKey string = 'AZURE-COSMOS-CONNECTION-STRING' 7 | param collections array = [ 8 | { 9 | name: 'TodoList' 10 | id: 'TodoList' 11 | shardKey: { 12 | keys: [ 13 | 'Hash' 14 | ] 15 | } 16 | indexes: [ 17 | { 18 | key: { 19 | keys: [ 20 | '_id' 21 | ] 22 | } 23 | } 24 | ] 25 | } 26 | { 27 | name: 'TodoItem' 28 | id: 'TodoItem' 29 | shardKey: { 30 | keys: [ 31 | 'Hash' 32 | ] 33 | } 34 | indexes: [ 35 | { 36 | key: { 37 | keys: [ 38 | '_id' 39 | ] 40 | } 41 | } 42 | ] 43 | } 44 | ] 45 | 46 | var defaultDatabaseName = 'Todo' 47 | var actualDatabaseName = !empty(cosmosDatabaseName) ? cosmosDatabaseName : defaultDatabaseName 48 | 49 | module cosmos 'br/public:avm/res/document-db/database-account:0.6.0' = { 50 | name: 'cosmos-mongo' 51 | params: { 52 | locations: [ 53 | { 54 | failoverPriority: 0 55 | isZoneRedundant: false 56 | locationName: location 57 | } 58 | ] 59 | name: accountName 60 | location: location 61 | mongodbDatabases: [ 62 | { 63 | name: actualDatabaseName 64 | tags: tags 65 | collections: collections 66 | } 67 | ] 68 | secretsExportConfiguration: { 69 | keyVaultResourceId: keyVaultResourceId 70 | primaryWriteConnectionStringSecretName: connectionStringKey 71 | } 72 | } 73 | } 74 | 75 | output connectionStringKey string = connectionStringKey 76 | output databaseName string = actualDatabaseName 77 | output endpoint string = cosmos.outputs.endpoint 78 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 6 | param environmentName string 7 | 8 | @minLength(1) 9 | @description('Primary location for all resources') 10 | param location string 11 | 12 | // Optional parameters to override the default azd resource naming conventions. Update the main.parameters.json file to provide values. e.g.,: 13 | // "resourceGroupName": { 14 | // "value": "myGroupName" 15 | // } 16 | param apiServiceName string = '' 17 | param applicationInsightsDashboardName string = '' 18 | param applicationInsightsName string = '' 19 | param appServicePlanName string = '' 20 | param cosmosAccountName string = '' 21 | param keyVaultName string = '' 22 | param logAnalyticsName string = '' 23 | param resourceGroupName string = '' 24 | param storageAccountName string = '' 25 | param webServiceName string = '' 26 | param apimServiceName string = '' 27 | 28 | @description('Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API') 29 | param useAPIM bool = false 30 | 31 | @description('API Management SKU to use if APIM is enabled') 32 | param apimSku string = 'Consumption' 33 | 34 | @description('Id of the user or app to assign application roles') 35 | param principalId string = '' 36 | 37 | var abbrs = loadJsonContent('./abbreviations.json') 38 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 39 | var tags = { 'azd-env-name': environmentName } 40 | var webUri = 'https://${web.outputs.defaultHostname}' 41 | 42 | // Organize resources in a resource group 43 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 44 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 45 | location: location 46 | tags: tags 47 | } 48 | 49 | // The application frontend 50 | module web 'br/public:avm/res/web/static-site:0.3.0' = { 51 | name: 'staticweb' 52 | scope: rg 53 | params: { 54 | name: !empty(webServiceName) ? webServiceName : '${abbrs.webStaticSites}web-${resourceToken}' 55 | location: location 56 | provider: 'Custom' 57 | tags: union(tags, { 'azd-service-name': 'web' }) 58 | } 59 | } 60 | 61 | // The application backend 62 | module api './app/api-appservice-avm.bicep' = { 63 | name: 'api' 64 | scope: rg 65 | params: { 66 | name: !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesAppService}api-${resourceToken}' 67 | location: location 68 | tags: tags 69 | kind: 'functionapp' 70 | appServicePlanId: appServicePlan.outputs.resourceId 71 | appSettings: { 72 | API_ALLOW_ORIGINS: webUri 73 | AZURE_COSMOS_CONNECTION_STRING_KEY: cosmos.outputs.connectionStringKey 74 | AZURE_COSMOS_DATABASE_NAME: cosmos.outputs.databaseName 75 | AZURE_KEY_VAULT_ENDPOINT:keyVault.outputs.uri 76 | AZURE_COSMOS_ENDPOINT: 'https://${cosmos.outputs.databaseName}.documents.azure.com:443/' 77 | FUNCTIONS_EXTENSION_VERSION: '~4' 78 | FUNCTIONS_WORKER_RUNTIME: 'python' 79 | SCM_DO_BUILD_DURING_DEPLOYMENT: true 80 | } 81 | appInsightResourceId: monitoring.outputs.applicationInsightsResourceId 82 | siteConfig: { 83 | linuxFxVersion: 'python|3.10' 84 | } 85 | allowedOrigins: [ webUri ] 86 | storageAccountResourceId: storage.outputs.resourceId 87 | clientAffinityEnabled: false 88 | } 89 | } 90 | 91 | // Give the API access to KeyVault 92 | module accessKeyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { 93 | name: 'accesskeyvault' 94 | scope: rg 95 | params: { 96 | name: keyVault.outputs.name 97 | enableRbacAuthorization: false 98 | enableVaultForDeployment: false 99 | enableVaultForTemplateDeployment: false 100 | enablePurgeProtection: false 101 | sku: 'standard' 102 | accessPolicies: [ 103 | { 104 | objectId: api.outputs.SERVICE_API_IDENTITY_PRINCIPAL_ID 105 | permissions: { 106 | secrets: [ 'get', 'list' ] 107 | } 108 | } 109 | { 110 | objectId: principalId 111 | permissions: { 112 | secrets: [ 'get', 'list' ] 113 | } 114 | } 115 | ] 116 | } 117 | } 118 | 119 | // The application database 120 | module cosmos './app/db-avm.bicep' = { 121 | name: 'cosmos' 122 | scope: rg 123 | params: { 124 | accountName: !empty(cosmosAccountName) ? cosmosAccountName : '${abbrs.documentDBDatabaseAccounts}${resourceToken}' 125 | location: location 126 | tags: tags 127 | keyVaultResourceId: keyVault.outputs.resourceId 128 | } 129 | } 130 | 131 | // Create an App Service Plan to group applications under the same payment plan and SKU 132 | module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.1' = { 133 | name: 'appserviceplan' 134 | scope: rg 135 | params: { 136 | name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' 137 | sku: { 138 | name: 'Y1' 139 | tier: 'Dynamic' 140 | } 141 | location: location 142 | tags: tags 143 | reserved: true 144 | kind: 'Linux' 145 | } 146 | } 147 | 148 | // Backing storage for Azure functions backend API 149 | module storage 'br/public:avm/res/storage/storage-account:0.8.3' = { 150 | name: 'storage' 151 | scope: rg 152 | params: { 153 | name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' 154 | allowBlobPublicAccess: true 155 | dnsEndpointType: 'Standard' 156 | publicNetworkAccess:'Enabled' 157 | networkAcls:{ 158 | bypass: 'AzureServices' 159 | defaultAction: 'Allow' 160 | } 161 | location: location 162 | tags: tags 163 | } 164 | } 165 | 166 | // Create a keyvault to store secrets 167 | module keyVault 'br/public:avm/res/key-vault/vault:0.5.1' = { 168 | name: 'keyvault' 169 | scope: rg 170 | params: { 171 | name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 172 | location: location 173 | tags: tags 174 | enableRbacAuthorization: false 175 | enableVaultForDeployment: false 176 | enableVaultForTemplateDeployment: false 177 | enablePurgeProtection: false 178 | sku: 'standard' 179 | } 180 | } 181 | 182 | // Monitor application with Azure Monitor 183 | module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { 184 | name: 'monitoring' 185 | scope: rg 186 | params: { 187 | applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 188 | logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 189 | applicationInsightsDashboardName: !empty(applicationInsightsDashboardName) ? applicationInsightsDashboardName : '${abbrs.portalDashboards}${resourceToken}' 190 | location: location 191 | tags: tags 192 | } 193 | } 194 | 195 | // Creates Azure API Management (APIM) service to mediate the requests between the frontend and the backend API 196 | module apim 'br/public:avm/res/api-management/service:0.2.0' = if (useAPIM) { 197 | name: 'apim-deployment' 198 | scope: rg 199 | params: { 200 | name: !empty(apimServiceName) ? apimServiceName : '${abbrs.apiManagementService}${resourceToken}' 201 | publisherEmail: 'noreply@microsoft.com' 202 | publisherName: 'n/a' 203 | location: location 204 | tags: tags 205 | sku: apimSku 206 | skuCount: 0 207 | zones: [] 208 | customProperties: {} 209 | loggers: [ 210 | { 211 | name: 'app-insights-logger' 212 | credentials: { 213 | instrumentationKey: monitoring.outputs.applicationInsightsInstrumentationKey 214 | } 215 | loggerDescription: 'Logger to Azure Application Insights' 216 | isBuffered: false 217 | loggerType: 'applicationInsights' 218 | targetResourceId: monitoring.outputs.applicationInsightsResourceId 219 | } 220 | ] 221 | } 222 | } 223 | 224 | //Configures the API settings for an api app within the Azure API Management (APIM) service. 225 | module apimApi 'br/public:avm/ptn/azd/apim-api:0.1.0' = if (useAPIM) { 226 | name: 'apim-api-deployment' 227 | scope: rg 228 | params: { 229 | apiBackendUrl: api.outputs.SERVICE_API_URI 230 | apiDescription: 'This is a simple Todo API' 231 | apiDisplayName: 'Simple Todo API' 232 | apiName: 'todo-api' 233 | apiPath: 'todo' 234 | name: useAPIM ? apim.outputs.name : '' 235 | webFrontendUrl: webUri 236 | location: location 237 | apiAppName: api.outputs.SERVICE_API_NAME 238 | } 239 | } 240 | 241 | // Data outputs 242 | output AZURE_COSMOS_CONNECTION_STRING_KEY string = cosmos.outputs.connectionStringKey 243 | output AZURE_COSMOS_DATABASE_NAME string = cosmos.outputs.databaseName 244 | 245 | // App outputs 246 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 247 | output AZURE_KEY_VAULT_ENDPOINT string = keyVault.outputs.uri 248 | output AZURE_KEY_VAULT_NAME string = keyVault.outputs.name 249 | output AZURE_LOCATION string = location 250 | output AZURE_TENANT_ID string = tenant().tenantId 251 | output API_BASE_URL string = useAPIM ? apimApi.outputs.serviceApiUri : api.outputs.SERVICE_API_URI 252 | output REACT_APP_WEB_BASE_URL string = webUri 253 | output USE_APIM bool = useAPIM 254 | output SERVICE_API_ENDPOINTS array = useAPIM ? [ apimApi.outputs.serviceApiUri, api.outputs.SERVICE_API_URI ]: [] 255 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "principalId": { 12 | "value": "${AZURE_PRINCIPAL_ID}" 13 | }, 14 | "useAPIM": { 15 | "value": "${USE_APIM=false}" 16 | }, 17 | "apimSku": { 18 | "value": "${APIM_SKU=Consumption}" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /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/catchAllFunction/__init__.py: -------------------------------------------------------------------------------- 1 | import azure.functions as func 2 | from azure.functions._http_asgi import AsgiResponse, AsgiRequest 3 | from todo import app # Main API application 4 | 5 | initialized = False 6 | 7 | async def ensure_init(app): 8 | global initialized 9 | if not initialized: 10 | await app.startup_event() 11 | initialized = True 12 | 13 | async def handle_asgi_request(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: 14 | asgi_request = AsgiRequest(req, context) 15 | scope = asgi_request.to_asgi_http_scope() 16 | asgi_response = await AsgiResponse.from_app(app.app, scope, req.get_body()) 17 | return asgi_response.to_func_response() 18 | 19 | async def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: 20 | await ensure_init(app) 21 | return await handle_asgi_request(req, context) 22 | -------------------------------------------------------------------------------- /src/api/catchAllFunction/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptFile": "__init__.py", 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "name": "req", 9 | "methods": [ 10 | "get", 11 | "put", 12 | "post", 13 | "patch", 14 | "delete" 15 | ], 16 | "route": "{*route}" 17 | }, 18 | { 19 | "type": "http", 20 | "direction": "out", 21 | "name": "$return" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /src/api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[2.*, 3.0.0)" 14 | }, 15 | "extensions": { 16 | "http": { 17 | "routePrefix": "" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/api/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "", 5 | "FUNCTIONS_WORKER_RUNTIME": "python" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /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-swa-func/dceed84e6fa3698062c6b8618ef689c239343639/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-swa-func/dceed84e6fa3698062c6b8618ef689c239343639/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 |