├── .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.bicep │ ├── db.bicep │ ├── rbac.bicep │ ├── sql-PrivateEndpoint.bicep │ ├── storage-PrivateEndpoint.bicep │ └── vnet.bicep ├── main.bicep └── main.parameters.json ├── openapi.yaml ├── src ├── api │ ├── .gitignore │ ├── ListsFunctions.cs │ ├── ListsRepository.cs │ ├── Program.cs │ ├── README.md │ ├── Todo.Api.csproj │ ├── TodoDb.cs │ ├── TodoItem.cs │ ├── TodoList.cs │ ├── host.json │ ├── local.settings.json │ └── openapi.yaml └── web │ ├── .dockerignore │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── index.html │ ├── nginx │ └── nginx.conf │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ └── manifest.json │ ├── src │ ├── @types │ │ └── window.d.ts │ ├── App.css │ ├── App.tsx │ ├── actions │ │ ├── actionCreators.ts │ │ ├── common.ts │ │ ├── itemActions.ts │ │ └── listActions.ts │ ├── components │ │ ├── telemetry.tsx │ │ ├── telemetryContext.ts │ │ ├── telemetryWithAppInsights.tsx │ │ ├── todoContext.ts │ │ ├── todoItemDetailPane.tsx │ │ ├── todoItemListPane.tsx │ │ └── todoListMenu.tsx │ ├── config │ │ └── index.ts │ ├── index.css │ ├── index.tsx │ ├── layout │ │ ├── header.tsx │ │ ├── layout.tsx │ │ └── sidebar.tsx │ ├── models │ │ ├── applicationState.ts │ │ ├── index.ts │ │ ├── todoItem.ts │ │ └── todoList.ts │ ├── pages │ │ └── homePage.tsx │ ├── react-app-env.d.ts │ ├── reducers │ │ ├── index.ts │ │ ├── listsReducer.ts │ │ ├── selectedItemReducer.ts │ │ └── selectedListReducer.ts │ ├── reportWebVitals.ts │ ├── services │ │ ├── itemService.ts │ │ ├── listService.ts │ │ ├── restService.ts │ │ └── telemetryService.ts │ ├── setupTests.ts │ └── ux │ │ ├── styles.ts │ │ └── theme.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── web.config └── tests ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── playwright.config.ts └── todo.spec.ts /.azdo/pipelines/azure-dev.yml: -------------------------------------------------------------------------------- 1 | # Run when commits are pushed to mainline branch (main or master) 2 | # Set this to the mainline branch you are using 3 | trigger: 4 | - main 5 | - master 6 | 7 | # Azure Pipelines workflow to deploy to Azure using azd 8 | # To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` 9 | # Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd 10 | # See below for alternative task to install azd if you can't install above task in your organization 11 | 12 | pool: 13 | vmImage: ubuntu-latest 14 | 15 | steps: 16 | - task: setup-azd@1 17 | displayName: Install azd 18 | 19 | # If you can't install above task in your organization, you can comment it and uncomment below task to install azd 20 | # - task: Bash@3 21 | # displayName: Install azd 22 | # inputs: 23 | # targetType: 'inline' 24 | # script: | 25 | # curl -fsSL https://aka.ms/install-azd.sh | bash 26 | 27 | # azd delegate auth to az to use service connection with AzureCLI@2 28 | - pwsh: | 29 | azd config set auth.useAzCliAuth "true" 30 | displayName: Configure AZD to Use AZ CLI Authentication. 31 | 32 | - task: AzureCLI@2 33 | displayName: Provision Infrastructure 34 | inputs: 35 | azureSubscription: azconnection 36 | scriptType: bash 37 | scriptLocation: inlineScript 38 | inlineScript: | 39 | azd provision --no-prompt 40 | env: 41 | AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) 42 | AZURE_ENV_NAME: $(AZURE_ENV_NAME) 43 | AZURE_LOCATION: $(AZURE_LOCATION) 44 | 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/dotnet:8.0-bookworm", 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-dotnettools.csharp", 22 | "ms-dotnettools.vscode-dotnet-runtime", 23 | "ms-vscode.vscode-node-azure-pack" 24 | ] 25 | } 26 | }, 27 | "forwardPorts": [ 28 | 3000, 29 | 3100 30 | ], 31 | "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", 32 | "remoteUser": "vscode", 33 | "hostRequirements": { 34 | "memory": "8gb" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.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 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Web", 6 | "request": "launch", 7 | "type": "msedge", 8 | "webRoot": "${workspaceFolder}/src/web/src", 9 | "url": "http://localhost:3000", 10 | "sourceMapPathOverrides": { 11 | "webpack:///src/*": "${webRoot}/*" 12 | }, 13 | }, 14 | { 15 | "name": "Debug API", 16 | "type": "coreclr", 17 | "request": "attach", 18 | "processId": "${command:pickProcess}" 19 | }, 20 | { 21 | "name": ".NET Core Attach", 22 | "type": "coreclr", 23 | "request": "attach" 24 | } 25 | ], 26 | "inputs": [ 27 | { 28 | "id": "dotEnvFilePath", 29 | "type": "command", 30 | "command": "azure-dev.commands.getDotEnvFilePath" 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.projectLanguage": "C#", 3 | "azureFunctions.projectRuntime": "~4" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start API", 6 | "type": "dotenv", 7 | "targetTasks": "API Functions Run", 8 | "file": "${input:dotEnvFilePath}" 9 | }, 10 | { 11 | "label": "Clean API", 12 | "command": "dotnet", 13 | "args": [ 14 | "clean", 15 | "${workspaceFolder}/src/api/Todo.Api.csproj", 16 | "/property:GenerateFullPaths=true", 17 | "/consoleloggerparameters:NoSummary" 18 | ], 19 | "options": { 20 | "cwd": "${workspaceFolder}/src/api/" 21 | }, 22 | "type": "process", 23 | "problemMatcher": "$msCompile" 24 | }, 25 | { 26 | "label": "Build API", 27 | "command": "dotnet", 28 | "args": [ 29 | "build", 30 | "${workspaceFolder}/src/api/Todo.Api.csproj", 31 | "/property:GenerateFullPaths=true", 32 | "/consoleloggerparameters:NoSummary" 33 | ], 34 | "type": "process", 35 | "dependsOn": "Clean API", 36 | "group": { 37 | "kind": "build", 38 | "isDefault": true 39 | }, 40 | "problemMatcher": "$msCompile" 41 | }, 42 | { 43 | "label": "API Functions Run", 44 | "detail": "Helper task--use 'Start API' task to ensure environment is set up correctly", 45 | "type": "func", 46 | "dependsOn": "Build API", 47 | "command": "host start", 48 | "options": { 49 | "cwd": "${workspaceFolder}/src/api/" 50 | }, 51 | "presentation": { 52 | "panel": "dedicated", 53 | }, 54 | "problemMatcher": [ 55 | "$func-dotnet-watch" 56 | ] 57 | }, 58 | { 59 | "label": "Watch API", 60 | "command": "dotnet", 61 | "type": "process", 62 | "args": [ 63 | "watch", 64 | "run", 65 | "--project", 66 | "${workspaceFolder}/src/api/Todo.Api.csproj" 67 | ], 68 | "problemMatcher": "$msCompile" 69 | }, 70 | { 71 | "label": "Start Web", 72 | "type": "dotenv", 73 | "targetTasks": [ 74 | "Restore Web", 75 | "Web npm start" 76 | ], 77 | "file": "${input:dotEnvFilePath}" 78 | }, 79 | { 80 | "label": "Restore Web", 81 | "type": "shell", 82 | "command": "azd restore web", 83 | "presentation": { 84 | "reveal": "silent" 85 | }, 86 | "problemMatcher": [] 87 | }, 88 | { 89 | "label": "Web npm start", 90 | "detail": "Helper task--use 'Start Web' task to ensure environment is set up correctly", 91 | "type": "shell", 92 | "command": "npx -y cross-env VITE_APPLICATIONINSIGHTS_CONNECTION_STRING=\"$APPLICATIONINSIGHTS_CONNECTION_STRING\" npm run dev", 93 | "options": { 94 | "cwd": "${workspaceFolder}/src/web/", 95 | "env": { 96 | "VITE_API_BASE_URL": "http://localhost:3100", 97 | "BROWSER": "none" 98 | } 99 | }, 100 | "presentation": { 101 | "panel": "dedicated", 102 | }, 103 | "problemMatcher": [] 104 | }, 105 | { 106 | "label": "Start API and Web", 107 | "dependsOn": [ 108 | "Start API", 109 | "Start Web" 110 | ], 111 | "problemMatcher": [] 112 | } 113 | ], 114 | "inputs": [ 115 | { 116 | "id": "dotEnvFilePath", 117 | "type": "command", 118 | "command": "azure-dev.commands.getDotEnvFilePath" 119 | } 120 | ] 121 | } -------------------------------------------------------------------------------- /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 | - aspx-csharp 6 | - csharp 7 | - bicep 8 | - typescript 9 | - html 10 | products: 11 | - azure 12 | - azure-sql-database 13 | - azure-functions 14 | - azure-monitor 15 | - azure-pipelines 16 | - aspnet-core 17 | urlFragment: todo-csharp-sql-swa-func 18 | name: Static React Web App + Functions with C# API and SQL Database on Azure 19 | description: A complete ToDo app with C# isolated Azure Functions for the API and Azure SQL database for storage. Uses Azure Developer CLI (azd) to build, deploy, and monitor 20 | --- 21 | 22 | 23 | # Static React Web App + Functions with C# API and SQL Database on Azure 24 | 25 | [![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-csharp-sql-swa-func) 26 | [![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-csharp-sql-swa-func) 27 | 28 | A blueprint for getting a React web app with a C# API and a SQL database running on Azure. The blueprint includes sample application code (a ToDo web app) which can be removed and replaced with your own application code. Add your own source code and leverage the Infrastructure as Code assets (written in Bicep) to get up and running quickly. This architecture is for hosting static web apps with serverless logic and functionality. 29 | 30 | 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. 31 | 32 | !["Screenshot of deployed ToDo app"](assets/web.png) 33 | 34 | Screenshot of the deployed ToDo app 35 | 36 | ### Prerequisites 37 | > 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. 38 | 39 | The following prerequisites are required to use this application. Please ensure that you have them all installed locally. 40 | 41 | - [Azure Developer CLI](https://aka.ms/azd-install) 42 | - [.NET SDK 8.0](https://dotnet.microsoft.com/download/dotnet/8.0) - for the API backend 43 | - [Azure Functions Core Tools (4+)](https://docs.microsoft.com/azure/azure-functions/functions-run-local) 44 | - [Node.js with npm (18.17.1+)](https://nodejs.org/) - for the Web frontend 45 | 46 | ### Quickstart 47 | To learn how to get started with any template, follow the steps in [this quickstart](https://learn.microsoft.com/azure/developer/azure-developer-cli/get-started?tabs=localinstall&pivots=programming-language-csharp) with this template(`Azure-Samples/todo-csharp-sql-swa-func`). 48 | 49 | 50 | 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: 51 | 52 | ```bash 53 | # Log in to azd. Only required once per-install. 54 | azd auth login 55 | 56 | # First-time project setup. Initialize a project in the current directory, using this template. 57 | azd init --template Azure-Samples/todo-csharp-sql-swa-func 58 | 59 | # Provision and deploy to Azure 60 | azd up 61 | ``` 62 | 63 | ### Application Architecture 64 | 65 | This application utilizes the following Azure resources: 66 | 67 | - [**Azure Static Web Apps**](https://docs.microsoft.com/azure/static-web-apps/) to host the Web frontend 68 | - [**Azure Function Apps**](https://docs.microsoft.com/azure/azure-functions/) to host the API backend 69 | - [**Azure SQL**](https://learn.microsoft.com/azure/azure-sql/database/sql-database-paas-overview?view=azuresql/) for storage 70 | - [**Azure Monitor**](https://docs.microsoft.com/azure/azure-monitor/) for monitoring and logging 71 | - [**Azure Key Vault**](https://docs.microsoft.com/azure/key-vault/) for securing secrets 72 | 73 | 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. 74 | 75 | !["Application architecture diagram"](assets/resources.png) 76 | 77 | ### Cost of provisioning and deploying this template 78 | 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. 79 | 80 | ### Application Code 81 | 82 | 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). 83 | 84 | ### Next Steps 85 | 86 | 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. 87 | 88 | > Note: Needs to manually install [setup-azd extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd) for Azure DevOps (azdo). 89 | 90 | - [`azd pipeline config`](https://learn.microsoft.com/en-us/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. 91 | 92 | - [`azd monitor`](https://learn.microsoft.com/en-us/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) 93 | 94 | - [Run and Debug Locally](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/debug?pivots=ide-vs-code) - using Visual Studio Code and the Azure Developer CLI extension 95 | 96 | - [`azd down`](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/reference#azd-down) - to delete all the Azure resources created with this template 97 | 98 | - [Enable optional features, like APIM](./OPTIONAL_FEATURES.md) - for enhanced backend API protection and observability 99 | 100 | ### Additional `azd` commands 101 | 102 | 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. 103 | 104 | ## Security 105 | 106 | ### Roles 107 | 108 | This template has been updated to be completely passwordless by default. Identity and Managed Identity is used instead in call cases. 109 | 110 | 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 e.g. the Storage account used for the Function's AzureWebJobsStorage or the optional Key Vault via access policies. 111 | 112 | 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. 113 | 114 | Additionally you will see a User Assigned Managed Identity (UAMI) for the API (FunctionApp) that is used by the Function to call dependencies such as Storage, SQL and KeyVault. If you set the optional enableSQLScripts flag to true, then an additional SQLAdmin UAMI will be created to do admin tasks like running scripts, and then the API UAMI will only be used for the app to read and write data. 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). 115 | 116 | ### Virtual Network and Private Endpoints (Optional) 117 | 118 | This template achieves an additional level of security by requiring all outbound traffic from your FunctionApp to its dependencies (e.g. Storage and SQL) using a VNET and Private Endpoints. This ensures true network isolation between the FunctionApp and the dependencies with a trusted endpoint for communication across the subnet boundary. To try this out select VNET_ENABLED=true when prompted. 119 | 120 | *Note* if you enable VNET, you will not be able to access your cloud resources such as Storage or SQL from your development machine by default. However you can mitigate this by adding your developer machine's IP address to the respective Firewall IP allow list for the service, or by VNET joining your development environment. 121 | 122 | ### Key Vault (Optional for running sql scripts) 123 | 124 | This template uses [Azure Key Vault](https://docs.microsoft.com/azure/key-vault/general/overview) to securely store your Azure SQL connection string for the provisioned Azure SQL Database, as well as to hold onto temporary values needed to run an admin script. 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. It is recommended to use Managed Identity whenever possible especially for Azure Services, however in cases where that is not possible, Key Vault is the recommendation. 125 | 126 | ## Reporting Issues and Feedback 127 | 128 | 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. 129 | -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-sql-swa-func/b8f150807ac07b920f14a5062a48edd479ad5586/assets/resources-with-apim.png -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-sql-swa-func/b8f150807ac07b920f14a5062a48edd479ad5586/assets/resources.png -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-sql-swa-func/b8f150807ac07b920f14a5062a48edd479ad5586/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-csharp-sql-swa-func/b8f150807ac07b920f14a5062a48edd479ad5586/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-csharp-sql-swa-func 4 | metadata: 5 | template: todo-csharp-sql-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: dotnet 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.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | @description('Primary location for all resources & Flex Consumption Function App') 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | param applicationInsightsName string = '' 6 | param appServicePlanId string 7 | param appSettings object = {} 8 | param runtimeName string 9 | param runtimeVersion string 10 | param serviceName string = 'api' 11 | param storageAccountName string 12 | param deploymentStorageContainerName string 13 | param virtualNetworkSubnetId string = '' 14 | param instanceMemoryMB int = 512 15 | param maximumInstanceCount int = 100 16 | param identityId string = '' 17 | param identityClientId string = '' 18 | param sqlAdminIdentityId string = '' 19 | param enableBlob bool = true 20 | param enableQueue bool = false 21 | param enableTable bool = false 22 | param enableFile bool = false 23 | param allowedOrigins array = [] 24 | 25 | @allowed(['SystemAssigned', 'UserAssigned']) 26 | param identityType string = 'UserAssigned' 27 | 28 | var applicationInsightsIdentity = 'ClientId=${identityClientId};Authorization=AAD' 29 | var kind = 'functionapp,linux' 30 | 31 | // Create base application settings 32 | var baseAppSettings = { 33 | // Only include required credential settings unconditionally 34 | AzureWebJobsStorage__credential: 'managedidentity' 35 | AzureWebJobsStorage__clientId: identityClientId 36 | 37 | // Application Insights settings are always included 38 | APPLICATIONINSIGHTS_AUTHENTICATION_STRING: applicationInsightsIdentity 39 | APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString 40 | } 41 | 42 | // Dynamically build storage endpoint settings based on feature flags 43 | var blobSettings = enableBlob ? { AzureWebJobsStorage__blobServiceUri: stg.properties.primaryEndpoints.blob } : {} 44 | var queueSettings = enableQueue ? { AzureWebJobsStorage__queueServiceUri: stg.properties.primaryEndpoints.queue } : {} 45 | var tableSettings = enableTable ? { AzureWebJobsStorage__tableServiceUri: stg.properties.primaryEndpoints.table } : {} 46 | var fileSettings = enableFile ? { AzureWebJobsStorage__fileServiceUri: stg.properties.primaryEndpoints.file } : {} 47 | 48 | // Merge all app settings 49 | var allAppSettings = union( 50 | appSettings, 51 | blobSettings, 52 | queueSettings, 53 | tableSettings, 54 | fileSettings, 55 | baseAppSettings 56 | ) 57 | 58 | resource stg 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { 59 | name: storageAccountName 60 | } 61 | 62 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { 63 | name: applicationInsightsName 64 | } 65 | 66 | // Create a Flex Consumption Function App to host the API 67 | module api 'br/public:avm/res/web/site:0.15.1' = { 68 | name: '${serviceName}-flex-consumption' 69 | params: { 70 | kind: kind 71 | name: name 72 | location: location 73 | tags: union(tags, { 'azd-service-name': serviceName }) 74 | serverFarmResourceId: appServicePlanId 75 | managedIdentities: { 76 | systemAssigned: identityType == 'SystemAssigned' 77 | userAssignedResourceIds: !empty(sqlAdminIdentityId) 78 | ? [identityId, sqlAdminIdentityId] 79 | : [identityId] 80 | } 81 | functionAppConfig: { 82 | deployment: { 83 | storage: { 84 | type: 'blobContainer' 85 | value: '${stg.properties.primaryEndpoints.blob}${deploymentStorageContainerName}' 86 | authentication: { 87 | type: identityType == 'SystemAssigned' ? 'SystemAssignedIdentity' : 'UserAssignedIdentity' 88 | userAssignedIdentityResourceId: identityType == 'UserAssigned' ? identityId : '' 89 | } 90 | } 91 | } 92 | scaleAndConcurrency: { 93 | instanceMemoryMB: instanceMemoryMB 94 | maximumInstanceCount: maximumInstanceCount 95 | } 96 | runtime: { 97 | name: runtimeName 98 | version: runtimeVersion 99 | } 100 | } 101 | siteConfig: { 102 | alwaysOn: false 103 | cors: { 104 | allowedOrigins: union(['https://portal.azure.com', 'https://ms.portal.azure.com', 'http://0.0.0.0:8000', 'http://localhost:8000'], allowedOrigins) 105 | } 106 | } 107 | virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null 108 | appSettingsKeyValuePairs: allAppSettings 109 | } 110 | } 111 | 112 | output SERVICE_API_NAME string = api.outputs.name 113 | // Ensure output is always string, handle potential null from module output if SystemAssigned is not used 114 | output SERVICE_API_IDENTITY_PRINCIPAL_ID string = identityType == 'SystemAssigned' ? api.outputs.?systemAssignedMIPrincipalId ?? '' : '' 115 | output SERVICE_API_URI string = 'https://${api.outputs.defaultHostname}' 116 | -------------------------------------------------------------------------------- /infra/app/db.bicep: -------------------------------------------------------------------------------- 1 | param location string 2 | param tags object = {} 3 | param sqlServerName string = '' 4 | param apiUserAssignedIdentityName string = '' 5 | param apiUserAssignedIdentityPrincipalId string = '' 6 | param apiUserAssignedIdentityClientId string = '' 7 | param sqlDatabaseName string = '' 8 | param enableSQLScripts bool = false 9 | param sqlAdminUserAssignedIdentityName string = '' 10 | param abbrs object 11 | param resourceToken string 12 | param keyVaultName string = '' 13 | param principalId string = '' 14 | param connectionStringKey string = 'AZURE-SQL-CONNECTION-STRING' 15 | param vnetEnabled bool = false 16 | 17 | // SQL admin user assigned identity - only created if enableSQLScripts is true 18 | // This identity is used for running SQL scripts with elevated permissions 19 | module sqlAdminUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = if (enableSQLScripts) { 20 | name: 'sqlAdminUserAssignedIdentity' 21 | params: { 22 | location: location 23 | tags: tags 24 | name: !empty(sqlAdminUserAssignedIdentityName) ? sqlAdminUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}sqladmin-${resourceToken}' 25 | } 26 | } 27 | 28 | // The application database 29 | var defaultDatabaseName = 'Todo' 30 | var actualDatabaseName = !empty(sqlDatabaseName) ? sqlDatabaseName : defaultDatabaseName 31 | 32 | module sqlServer 'br/public:avm/res/sql/server:0.16.1' = { 33 | name: 'sqlservice' 34 | params: { 35 | name: !empty(sqlServerName) ? sqlServerName : '${abbrs.sqlServers}${resourceToken}' 36 | location: location 37 | tags: tags 38 | publicNetworkAccess: vnetEnabled ? 'Disabled' : 'Enabled' 39 | administrators: { 40 | azureADOnlyAuthentication: true 41 | login: apiUserAssignedIdentityName 42 | principalType: 'Application' 43 | sid: apiUserAssignedIdentityClientId 44 | tenantId: tenant().tenantId 45 | } 46 | databases: [ 47 | { 48 | name: actualDatabaseName 49 | availabilityZone: -1 50 | zoneRedundant: false 51 | } 52 | ] 53 | firewallRules: !vnetEnabled ? [ 54 | { 55 | name: 'Azure Services' 56 | startIpAddress: '0.0.0.1' 57 | endIpAddress: '255.255.255.254' 58 | } 59 | ] : [] 60 | } 61 | } 62 | 63 | // Optional deployment script to add the API user to the database 64 | module deploymentScript 'br/public:avm/res/resources/deployment-script:0.1.3' = if (enableSQLScripts) { 65 | name: 'deployment-script' 66 | params: { 67 | kind: 'AzureCLI' 68 | name: 'deployment-script' 69 | azCliVersion: '2.37.0' 70 | location: location 71 | retentionInterval: 'PT1H' 72 | timeout: 'PT5M' 73 | cleanupPreference: 'OnSuccess' 74 | environmentVariables:{ 75 | secureList: [ 76 | { 77 | name: 'DBNAME' 78 | value: actualDatabaseName 79 | } 80 | { 81 | name: 'DBSERVER' 82 | value: '${sqlServer.outputs.name}${environment().suffixes.sqlServerHostname}' 83 | } 84 | { 85 | name: 'UAMIOBJECTID-API' 86 | secureValue: apiUserAssignedIdentityPrincipalId 87 | } 88 | { 89 | name: 'UAMINAME-API' 90 | secureValue: apiUserAssignedIdentityName 91 | } 92 | { 93 | name: 'UAMICLIENTID-SQLADMIN' 94 | secureValue: enableSQLScripts ? sqlAdminUserAssignedIdentity.outputs.clientId : '' 95 | } 96 | ] 97 | } 98 | // Uses sqlAdminUserAssignedIdentity to run the script as elevated SQL admin 99 | // Adds the apiUserAssignedIdentity (normal app/api user) to the database and assigns it the db_datareader, db_datawriter, and db_ddladmin roles 100 | // More info: https://learn.microsoft.com/en-us/azure/app-service/tutorial-connect-msi-sql-database?tabs=windowsclient%2Cefcore%2Cdotnet#grant-permissions-to-managed-identity 101 | scriptContent: ''' 102 | wget https://github.com/microsoft/go-sqlcmd/releases/download/v0.8.1/sqlcmd-v0.8.1-linux-x64.tar.bz2 103 | tar x -f sqlcmd-v0.8.1-linux-x64.tar.bz2 -C . 104 | 105 | cat < ./initDb.sql 106 | drop user if exists ${UAMINAME-API} 107 | go 108 | CREATE USER [${UAMINAME-API}] FROM EXTERNAL PROVIDER With OBJECT_ID='${UAMIOBJECTID-API}'; 109 | ALTER ROLE db_datareader ADD MEMBER [${UAMINAME-API}]; 110 | ALTER ROLE db_datawriter ADD MEMBER [${UAMINAME-API}]; 111 | ALTER ROLE db_ddladmin ADD MEMBER [${UAMINAME-API}]; 112 | GO 113 | SCRIPT_END 114 | 115 | ./sqlcmd -S ${DBSERVER} -d ${DBNAME} --authentication-method ActiveDirectoryManagedIdentity -U {UAMICLIENTID-SQLADMIN} -i ./initDb.sql 116 | ''' 117 | } 118 | } 119 | 120 | // Create a keyvault to store secrets - only created if enableSQLScripts is true 121 | module keyVault 'br/public:avm/res/key-vault/vault:0.5.1' = if (enableSQLScripts) { 122 | name: 'keyvault' 123 | params: { 124 | name: !empty(keyVaultName) ? keyVaultName : '${abbrs.keyVaultVaults}${resourceToken}' 125 | location: location 126 | tags: tags 127 | enableRbacAuthorization: false 128 | enableVaultForDeployment: false 129 | enableVaultForTemplateDeployment: false 130 | enablePurgeProtection: false 131 | sku: 'standard' 132 | } 133 | } 134 | 135 | // Give the API access to KeyVault - only if enableSQLScripts is true 136 | module accessKeyVault 'br/public:avm/res/key-vault/vault:0.5.1' = if (enableSQLScripts) { 137 | name: 'accesskeyvault' 138 | params: { 139 | name: keyVault.outputs.name 140 | enableRbacAuthorization: false 141 | enableVaultForDeployment: false 142 | enableVaultForTemplateDeployment: false 143 | enablePurgeProtection: false 144 | sku: 'standard' 145 | accessPolicies: [ 146 | { 147 | objectId: principalId 148 | permissions: { 149 | secrets: [ 'get', 'list' ] 150 | } 151 | } 152 | { 153 | objectId: apiUserAssignedIdentityPrincipalId 154 | permissions: { 155 | secrets: [ 'get', 'list' ] 156 | } 157 | } 158 | ] 159 | secrets:{ 160 | secureList: [ 161 | { 162 | name: connectionStringKey 163 | value: 'Server=${sqlServer.outputs.fullyQualifiedDomainName}; Database=${actualDatabaseName}; Authentication=Active Directory Default; User Id=${apiUserAssignedIdentityClientId}; TrustServerCertificate=True' 164 | } 165 | ] 166 | } 167 | } 168 | } 169 | 170 | // Outputs 171 | output fullyQualifiedDomainName string = sqlServer.outputs.fullyQualifiedDomainName 172 | output name string = sqlServer.outputs.name 173 | output databaseName string = actualDatabaseName 174 | output keyVaultName string = enableSQLScripts ? keyVault.outputs.name : '' 175 | output keyVaultUri string = enableSQLScripts ? keyVault.outputs.uri : '' 176 | output sqlAdminIdentityId string = enableSQLScripts ? sqlAdminUserAssignedIdentity.outputs.resourceId : '' 177 | output sqlAdminIdentityClientId string = enableSQLScripts ? sqlAdminUserAssignedIdentity.outputs.clientId : '' 178 | -------------------------------------------------------------------------------- /infra/app/rbac.bicep: -------------------------------------------------------------------------------- 1 | param storageAccountName string 2 | param appInsightsName string 3 | param managedIdentityPrincipalId string // Principal ID for the Managed Identity 4 | param userIdentityPrincipalId string = '' // Principal ID for the User Identity 5 | param allowUserIdentityPrincipal bool = false // Flag to enable user identity role assignments 6 | param enableBlob bool = true 7 | param enableQueue bool = false 8 | param enableTable bool = false 9 | 10 | // Define Role Definition IDs internally 11 | var storageRoleDefinitionId = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' //Storage Blob Data Owner role 12 | var queueRoleDefinitionId = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' // Storage Queue Data Contributor role 13 | var tableRoleDefinitionId = '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' // Storage Table Data Contributor role 14 | var monitoringRoleDefinitionId = '3913510d-42f4-4e42-8a64-420c390055eb' // Monitoring Metrics Publisher role ID 15 | 16 | resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { 17 | name: storageAccountName 18 | } 19 | 20 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { 21 | name: appInsightsName 22 | } 23 | 24 | // Role assignment for Storage Account (Blob) - Managed Identity 25 | resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableBlob) { 26 | name: guid(storageAccount.id, managedIdentityPrincipalId, storageRoleDefinitionId) // Use managed identity ID 27 | scope: storageAccount 28 | properties: { 29 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageRoleDefinitionId) 30 | principalId: managedIdentityPrincipalId // Use managed identity ID 31 | principalType: 'ServicePrincipal' // Managed Identity is a Service Principal 32 | } 33 | } 34 | 35 | // Role assignment for Storage Account (Blob) - User Identity 36 | resource storageRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableBlob && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { 37 | name: guid(storageAccount.id, userIdentityPrincipalId, storageRoleDefinitionId) 38 | scope: storageAccount 39 | properties: { 40 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', storageRoleDefinitionId) 41 | principalId: userIdentityPrincipalId // Use user identity ID 42 | principalType: 'User' // User Identity is a User Principal 43 | } 44 | } 45 | 46 | // Role assignment for Storage Account (Queue) - Managed Identity 47 | resource queueRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableQueue) { 48 | name: guid(storageAccount.id, managedIdentityPrincipalId, queueRoleDefinitionId) // Use managed identity ID 49 | scope: storageAccount 50 | properties: { 51 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', queueRoleDefinitionId) 52 | principalId: managedIdentityPrincipalId // Use managed identity ID 53 | principalType: 'ServicePrincipal' // Managed Identity is a Service Principal 54 | } 55 | } 56 | 57 | // Role assignment for Storage Account (Queue) - User Identity 58 | resource queueRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableQueue && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { 59 | name: guid(storageAccount.id, userIdentityPrincipalId, queueRoleDefinitionId) 60 | scope: storageAccount 61 | properties: { 62 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', queueRoleDefinitionId) 63 | principalId: userIdentityPrincipalId // Use user identity ID 64 | principalType: 'User' // User Identity is a User Principal 65 | } 66 | } 67 | 68 | // Role assignment for Storage Account (Table) - Managed Identity 69 | resource tableRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableTable) { 70 | name: guid(storageAccount.id, managedIdentityPrincipalId, tableRoleDefinitionId) // Use managed identity ID 71 | scope: storageAccount 72 | properties: { 73 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', tableRoleDefinitionId) 74 | principalId: managedIdentityPrincipalId // Use managed identity ID 75 | principalType: 'ServicePrincipal' // Managed Identity is a Service Principal 76 | } 77 | } 78 | 79 | // Role assignment for Storage Account (Table) - User Identity 80 | resource tableRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableTable && allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { 81 | name: guid(storageAccount.id, userIdentityPrincipalId, tableRoleDefinitionId) 82 | scope: storageAccount 83 | properties: { 84 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', tableRoleDefinitionId) 85 | principalId: userIdentityPrincipalId // Use user identity ID 86 | principalType: 'User' // User Identity is a User Principal 87 | } 88 | } 89 | 90 | // Role assignment for Application Insights - Managed Identity 91 | resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { 92 | name: guid(applicationInsights.id, managedIdentityPrincipalId, monitoringRoleDefinitionId) // Use managed identity ID 93 | scope: applicationInsights 94 | properties: { 95 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', monitoringRoleDefinitionId) 96 | principalId: managedIdentityPrincipalId // Use managed identity ID 97 | principalType: 'ServicePrincipal' // Managed Identity is a Service Principal 98 | } 99 | } 100 | 101 | // Role assignment for Application Insights - User Identity 102 | resource appInsightsRoleAssignment_User 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (allowUserIdentityPrincipal && !empty(userIdentityPrincipalId)) { 103 | name: guid(applicationInsights.id, userIdentityPrincipalId, monitoringRoleDefinitionId) 104 | scope: applicationInsights 105 | properties: { 106 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', monitoringRoleDefinitionId) 107 | principalId: userIdentityPrincipalId // Use user identity ID 108 | principalType: 'User' // User Identity is a User Principal 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /infra/app/sql-PrivateEndpoint.bicep: -------------------------------------------------------------------------------- 1 | @description('Location for the private endpoint') 2 | param location string 3 | @description('Tags for the resource') 4 | param tags object = {} 5 | @description('Name of the virtual network') 6 | param virtualNetworkName string 7 | @description('Name of the subnet for the private endpoint') 8 | param subnetName string 9 | @description('Name of the SQL server') 10 | param sqlServerName string 11 | 12 | resource sqlPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = { 13 | name: 'sql-private-endpoint' 14 | location: location 15 | tags: tags 16 | properties: { 17 | subnet: { 18 | id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, subnetName) 19 | } 20 | privateLinkServiceConnections: [ 21 | { 22 | name: '${sqlServerName}-plsc' 23 | properties: { 24 | privateLinkServiceId: resourceId('Microsoft.Sql/servers', sqlServerName) 25 | groupIds: [ 'sqlServer' ] 26 | } 27 | } 28 | ] 29 | } 30 | } 31 | 32 | output privateEndpointId string = sqlPrivateEndpoint.id 33 | output privateEndpointName string = sqlPrivateEndpoint.name 34 | -------------------------------------------------------------------------------- /infra/app/storage-PrivateEndpoint.bicep: -------------------------------------------------------------------------------- 1 | param virtualNetworkName string 2 | param subnetName string 3 | @description('Specifies the storage account resource name') 4 | param resourceName string 5 | param location string = resourceGroup().location 6 | param tags object = {} 7 | param enableBlob bool = true 8 | param enableQueue bool = false 9 | param enableTable bool = false 10 | 11 | resource vnet 'Microsoft.Network/virtualNetworks@2021-08-01' existing = { 12 | name: virtualNetworkName 13 | } 14 | 15 | resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { 16 | name: resourceName 17 | } 18 | 19 | // Storage DNS zone names 20 | var blobPrivateDNSZoneName = 'privatelink.blob.${environment().suffixes.storage}' 21 | var queuePrivateDNSZoneName = 'privatelink.queue.${environment().suffixes.storage}' 22 | var tablePrivateDNSZoneName = 'privatelink.table.${environment().suffixes.storage}' 23 | 24 | // AVM module for Blob Private Endpoint with private DNS zone 25 | module blobPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableBlob) { 26 | name: 'blob-private-endpoint-deployment' 27 | params: { 28 | name: 'blob-private-endpoint' 29 | location: location 30 | tags: tags 31 | subnetResourceId: '${vnet.id}/subnets/${subnetName}' 32 | privateLinkServiceConnections: [ 33 | { 34 | name: 'blobPrivateLinkConnection' 35 | properties: { 36 | privateLinkServiceId: storageAccount.id 37 | groupIds: [ 38 | 'blob' 39 | ] 40 | } 41 | } 42 | ] 43 | customDnsConfigs: [] 44 | // Creates private DNS zone and links 45 | privateDnsZoneGroup: { 46 | name: 'blobPrivateDnsZoneGroup' 47 | privateDnsZoneGroupConfigs: [ 48 | { 49 | name: 'storageBlobARecord' 50 | privateDnsZoneResourceId: enableBlob ? privateDnsZoneBlobDeployment.outputs.resourceId : '' 51 | } 52 | ] 53 | } 54 | } 55 | } 56 | 57 | // AVM module for Queue Private Endpoint with private DNS zone 58 | module queuePrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableQueue) { 59 | name: 'queue-private-endpoint-deployment' 60 | params: { 61 | name: 'queue-private-endpoint' 62 | location: location 63 | tags: tags 64 | subnetResourceId: '${vnet.id}/subnets/${subnetName}' 65 | privateLinkServiceConnections: [ 66 | { 67 | name: 'queuePrivateLinkConnection' 68 | properties: { 69 | privateLinkServiceId: storageAccount.id 70 | groupIds: [ 71 | 'queue' 72 | ] 73 | } 74 | } 75 | ] 76 | customDnsConfigs: [] 77 | // Creates private DNS zone and links 78 | privateDnsZoneGroup: { 79 | name: 'queuePrivateDnsZoneGroup' 80 | privateDnsZoneGroupConfigs: [ 81 | { 82 | name: 'storageQueueARecord' 83 | privateDnsZoneResourceId: enableQueue ? privateDnsZoneQueueDeployment.outputs.resourceId : '' 84 | } 85 | ] 86 | } 87 | } 88 | } 89 | 90 | // AVM module for Table Private Endpoint with private DNS zone 91 | module tablePrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if (enableTable) { 92 | name: 'table-private-endpoint-deployment' 93 | params: { 94 | name: 'table-private-endpoint' 95 | location: location 96 | tags: tags 97 | subnetResourceId: '${vnet.id}/subnets/${subnetName}' 98 | privateLinkServiceConnections: [ 99 | { 100 | name: 'tablePrivateLinkConnection' 101 | properties: { 102 | privateLinkServiceId: storageAccount.id 103 | groupIds: [ 104 | 'table' 105 | ] 106 | } 107 | } 108 | ] 109 | customDnsConfigs: [] 110 | // Creates private DNS zone and links 111 | privateDnsZoneGroup: { 112 | name: 'tablePrivateDnsZoneGroup' 113 | privateDnsZoneGroupConfigs: [ 114 | { 115 | name: 'storageTableARecord' 116 | privateDnsZoneResourceId: enableTable ? privateDnsZoneTableDeployment.outputs.resourceId : '' 117 | } 118 | ] 119 | } 120 | } 121 | } 122 | 123 | // AVM module for Blob Private DNS Zone 124 | module privateDnsZoneBlobDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableBlob) { 125 | name: 'blob-private-dns-zone-deployment' 126 | params: { 127 | name: blobPrivateDNSZoneName 128 | location: 'global' 129 | tags: tags 130 | virtualNetworkLinks: [ 131 | { 132 | name: '${resourceName}-blob-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' 133 | virtualNetworkResourceId: vnet.id 134 | registrationEnabled: false 135 | location: 'global' 136 | tags: tags 137 | } 138 | ] 139 | } 140 | } 141 | 142 | // AVM module for Queue Private DNS Zone 143 | module privateDnsZoneQueueDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableQueue) { 144 | name: 'queue-private-dns-zone-deployment' 145 | params: { 146 | name: queuePrivateDNSZoneName 147 | location: 'global' 148 | tags: tags 149 | virtualNetworkLinks: [ 150 | { 151 | name: '${resourceName}-queue-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' 152 | virtualNetworkResourceId: vnet.id 153 | registrationEnabled: false 154 | location: 'global' 155 | tags: tags 156 | } 157 | ] 158 | } 159 | } 160 | 161 | // AVM module for Table Private DNS Zone 162 | module privateDnsZoneTableDeployment 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enableTable) { 163 | name: 'table-private-dns-zone-deployment' 164 | params: { 165 | name: tablePrivateDNSZoneName 166 | location: 'global' 167 | tags: tags 168 | virtualNetworkLinks: [ 169 | { 170 | name: '${resourceName}-table-link-${take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)}' 171 | virtualNetworkResourceId: vnet.id 172 | registrationEnabled: false 173 | location: 'global' 174 | tags: tags 175 | } 176 | ] 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /infra/app/vnet.bicep: -------------------------------------------------------------------------------- 1 | @description('Specifies the name of the virtual network.') 2 | param vNetName string 3 | 4 | @description('Specifies the location.') 5 | param location string = resourceGroup().location 6 | 7 | @description('Specifies the name of the subnet for the Service Bus private endpoint.') 8 | param peSubnetName string = 'private-endpoints-subnet' 9 | 10 | @description('Specifies the name of the subnet for Function App virtual network integration.') 11 | param appSubnetName string = 'app' 12 | 13 | @description('Specifies the name of the subnet for SQL private endpoint.') 14 | param sqlSubnetName string = 'sql-private-endpoints-subnet' 15 | 16 | param tags object = {} 17 | 18 | // Migrated to use AVM module instead of direct resource declaration 19 | module virtualNetwork 'br/public:avm/res/network/virtual-network:0.6.1' = { 20 | name: 'vnet-deployment' 21 | params: { 22 | // Required parameters 23 | name: vNetName 24 | addressPrefixes: [ 25 | '10.0.0.0/16' 26 | ] 27 | // Non-required parameters 28 | location: location 29 | tags: tags 30 | subnets: [ 31 | { 32 | name: peSubnetName 33 | addressPrefix: '10.0.1.0/24' 34 | privateEndpointNetworkPolicies: 'Disabled' 35 | privateLinkServiceNetworkPolicies: 'Enabled' 36 | } 37 | { 38 | name: appSubnetName 39 | addressPrefix: '10.0.2.0/24' 40 | privateEndpointNetworkPolicies: 'Disabled' 41 | privateLinkServiceNetworkPolicies: 'Enabled' 42 | delegation: 'Microsoft.App/environments' 43 | } 44 | { 45 | name: sqlSubnetName 46 | addressPrefix: '10.0.3.0/24' 47 | privateEndpointNetworkPolicies: 'Disabled' 48 | privateLinkServiceNetworkPolicies: 'Enabled' 49 | } 50 | ] 51 | } 52 | } 53 | 54 | output peSubnetName string = peSubnetName 55 | output peSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${peSubnetName}' 56 | output appSubnetName string = appSubnetName 57 | output appSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${appSubnetName}' 58 | output sqlSubnetName string = sqlSubnetName 59 | output sqlSubnetID string = '${virtualNetwork.outputs.resourceId}/subnets/${sqlSubnetName}' 60 | -------------------------------------------------------------------------------- /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 & Flex Consumption Function App') 10 | @allowed([ 11 | 'australiaeast' 12 | 'australiasoutheast' 13 | 'brazilsouth' 14 | 'canadacentral' 15 | 'centralindia' 16 | 'centralus' 17 | 'eastasia' 18 | 'eastus' 19 | 'eastus2' 20 | 'eastus2euap' 21 | 'francecentral' 22 | 'germanywestcentral' 23 | 'italynorth' 24 | 'japaneast' 25 | 'koreacentral' 26 | 'northcentralus' 27 | 'northeurope' 28 | 'norwayeast' 29 | 'southafricanorth' 30 | 'southcentralus' 31 | 'southeastasia' 32 | 'southindia' 33 | 'spaincentral' 34 | 'swedencentral' 35 | 'uaenorth' 36 | 'uksouth' 37 | 'ukwest' 38 | 'westcentralus' 39 | 'westeurope' 40 | 'westus' 41 | 'westus2' 42 | 'westus3' 43 | ]) 44 | @metadata({ 45 | azd: { 46 | type: 'location' 47 | } 48 | }) 49 | param location string 50 | param apiServiceName string = '' 51 | param apiUserAssignedIdentityName string = '' 52 | param applicationInsightsName string = '' 53 | param appServicePlanName string = '' 54 | param keyVaultName string = '' 55 | param logAnalyticsName string = '' 56 | param resourceGroupName string = '' 57 | param storageAccountName string = '' 58 | param sqlServerName string = '' 59 | param webServiceName string = '' 60 | param apimServiceName string = '' 61 | param connectionStringKey string = 'AZURE-SQL-CONNECTION-STRING' 62 | 63 | @description('Flag to enable SQL scripts for database user and role setup') 64 | param enableSQLScripts bool = false 65 | 66 | @description('Flag to use Azure API Management to mediate the calls between the Web frontend and the backend API') 67 | param useAPIM bool = false 68 | 69 | @description('API Management SKU to use if APIM is enabled') 70 | param apimSku string = 'Consumption' 71 | 72 | param vnetEnabled bool 73 | param vNetName string = '' 74 | 75 | @description('Id of the user or app to assign application roles') 76 | param principalId string = '' 77 | 78 | param sqlDatabaseName string = '' 79 | 80 | var deploymentStorageContainerName = 'app-package-${take(functionAppName, 32)}-${take(toLower(uniqueString(functionAppName, resourceToken)), 7)}' 81 | var abbrs = loadJsonContent('./abbreviations.json') 82 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 83 | var tags = { 'azd-env-name': environmentName } 84 | var webUri = 'https://${web.outputs.defaultHostname}' 85 | var functionAppName = !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesFunctions}api-${resourceToken}' 86 | 87 | 88 | // Organize resources in a resource group 89 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 90 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 91 | location: location 92 | tags: tags 93 | } 94 | 95 | // User assigned managed identity to be used by the function app to reach storage and other dependencies 96 | // Assign specific roles to this identity in the RBAC module 97 | module apiUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.4.1' = { 98 | name: 'apiUserAssignedIdentity' 99 | scope: rg 100 | params: { 101 | location: location 102 | tags: tags 103 | name: !empty(apiUserAssignedIdentityName) ? apiUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}api-${resourceToken}' 104 | } 105 | } 106 | 107 | // The application frontend 108 | module web 'br/public:avm/res/web/static-site:0.9.0' = { 109 | name: 'staticweb' 110 | scope: rg 111 | params: { 112 | name: !empty(webServiceName) ? webServiceName : '${abbrs.webStaticSites}web-${resourceToken}' 113 | location: location 114 | provider: 'Custom' 115 | tags: union(tags, { 'azd-service-name': 'web' }) 116 | } 117 | } 118 | 119 | // Create an App Service Plan to group applications under the same payment plan and SKU 120 | module appServicePlan 'br/public:avm/res/web/serverfarm:0.1.1' = { 121 | name: 'appserviceplan' 122 | scope: rg 123 | params: { 124 | name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' 125 | sku: { 126 | name: 'FC1' 127 | tier: 'FlexConsumption' 128 | } 129 | reserved: true 130 | location: location 131 | tags: tags 132 | } 133 | } 134 | 135 | module api './app/api.bicep' = { 136 | name: 'api' 137 | scope: rg 138 | params: { 139 | name: functionAppName 140 | location: location 141 | tags: tags 142 | applicationInsightsName: monitoring.outputs.name 143 | appServicePlanId: appServicePlan.outputs.resourceId 144 | runtimeName: 'dotnet-isolated' 145 | runtimeVersion: '8.0' 146 | storageAccountName: storage.outputs.name 147 | enableBlob: storageEndpointConfig.enableBlob 148 | enableQueue: storageEndpointConfig.enableQueue 149 | enableTable: storageEndpointConfig.enableTable 150 | deploymentStorageContainerName: deploymentStorageContainerName 151 | identityId: apiUserAssignedIdentity.outputs.resourceId 152 | identityClientId: apiUserAssignedIdentity.outputs.clientId 153 | sqlAdminIdentityId: enableSQLScripts ? db.outputs.sqlAdminIdentityId : '' 154 | appSettings: { 155 | AZURE_KEY_VAULT_ENDPOINT: enableSQLScripts ? db.outputs.keyVaultUri : '' 156 | AZURE_SQL_CONNECTION_STRING_KEY: 'Server=${db.outputs.name}${environment().suffixes.sqlServerHostname}; Database=${db.outputs.databaseName}; Authentication=Active Directory Default; User Id=${apiUserAssignedIdentity.outputs.clientId}; TrustServerCertificate=True' 157 | } 158 | virtualNetworkSubnetId: vnetEnabled ? serviceVirtualNetwork.outputs.appSubnetID : '' 159 | allowedOrigins: [ webUri ] 160 | } 161 | } 162 | 163 | // The application database 164 | // Import the database resources from db.bicep 165 | module db './app/db.bicep' = { 166 | name: 'database' 167 | scope: rg 168 | params: { 169 | location: location 170 | tags: tags 171 | sqlServerName: sqlServerName 172 | abbrs: abbrs 173 | resourceToken: resourceToken 174 | sqlDatabaseName: sqlDatabaseName 175 | apiUserAssignedIdentityName: apiUserAssignedIdentity.outputs.name 176 | apiUserAssignedIdentityPrincipalId: apiUserAssignedIdentity.outputs.principalId 177 | apiUserAssignedIdentityClientId: apiUserAssignedIdentity.outputs.clientId 178 | enableSQLScripts: enableSQLScripts 179 | keyVaultName: keyVaultName 180 | principalId: principalId 181 | connectionStringKey: connectionStringKey 182 | } 183 | } 184 | 185 | module storage 'br/public:avm/res/storage/storage-account:0.8.3' = { 186 | name: 'storage' 187 | scope: rg 188 | params: { 189 | name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' 190 | allowBlobPublicAccess: false 191 | allowSharedKeyAccess: false // Disable local authentication methods as per policy 192 | dnsEndpointType: 'Standard' 193 | publicNetworkAccess: vnetEnabled ? 'Disabled' : 'Enabled' 194 | networkAcls: vnetEnabled ? { 195 | defaultAction: 'Deny' 196 | bypass: 'None' 197 | } : { 198 | defaultAction: 'Allow' 199 | bypass: 'AzureServices' 200 | } 201 | blobServices: { 202 | containers: [{name: deploymentStorageContainerName}] 203 | } 204 | minimumTlsVersion: 'TLS1_2' // Enforcing TLS 1.2 for better security 205 | location: location 206 | tags: tags 207 | } 208 | } 209 | 210 | // Define the configuration object locally to pass to the modules 211 | var storageEndpointConfig = { 212 | enableBlob: true // Required for AzureWebJobsStorage, .zip deployment, Event Hubs trigger and Timer trigger checkpointing 213 | enableQueue: false // Required for Durable Functions and MCP trigger 214 | enableTable: false // Required for Durable Functions and OpenAI triggers and bindings 215 | enableFiles: false // Not required, used in legacy scenarios 216 | allowUserIdentityPrincipal: true // Allow interactive user identity to access for testing and debugging 217 | } 218 | 219 | // Consolidated Role Assignments 220 | module rbac 'app/rbac.bicep' = { 221 | name: 'rbacAssignments' 222 | scope: rg 223 | params: { 224 | storageAccountName: storage.outputs.name 225 | appInsightsName: monitoring.outputs.name 226 | managedIdentityPrincipalId: apiUserAssignedIdentity.outputs.principalId 227 | userIdentityPrincipalId: principalId 228 | enableBlob: storageEndpointConfig.enableBlob 229 | enableQueue: storageEndpointConfig.enableQueue 230 | enableTable: storageEndpointConfig.enableTable 231 | allowUserIdentityPrincipal: storageEndpointConfig.allowUserIdentityPrincipal 232 | } 233 | } 234 | 235 | // Virtual Network & private endpoint to blob storage 236 | module serviceVirtualNetwork 'app/vnet.bicep' = if (vnetEnabled) { 237 | name: 'serviceVirtualNetwork' 238 | scope: rg 239 | params: { 240 | location: location 241 | tags: tags 242 | vNetName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' 243 | } 244 | } 245 | 246 | module storagePrivateEndpoint 'app/storage-PrivateEndpoint.bicep' = if (vnetEnabled) { 247 | name: 'servicePrivateEndpoint' 248 | scope: rg 249 | params: { 250 | location: location 251 | tags: tags 252 | virtualNetworkName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' 253 | subnetName: vnetEnabled ? serviceVirtualNetwork.outputs.peSubnetName : '' // Keep conditional check for safety, though module won't run if !vnetEnabled 254 | resourceName: storage.outputs.name 255 | enableBlob: storageEndpointConfig.enableBlob 256 | enableQueue: storageEndpointConfig.enableQueue 257 | enableTable: storageEndpointConfig.enableTable 258 | } 259 | } 260 | 261 | // SQL Server private endpoint 262 | module sqlPrivateEndpoint 'app/sql-PrivateEndpoint.bicep' = if (vnetEnabled) { 263 | name: 'sqlPrivateEndpoint' 264 | scope: rg 265 | params: { 266 | location: location 267 | tags: tags 268 | virtualNetworkName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' 269 | subnetName: vnetEnabled ? serviceVirtualNetwork.outputs.sqlSubnetName : '' 270 | sqlServerName: db.outputs.name 271 | } 272 | } 273 | 274 | // Monitor application with Azure Monitor - Log Analytics and Application Insights 275 | module logAnalytics 'br/public:avm/res/operational-insights/workspace:0.11.1' = { 276 | name: '${uniqueString(deployment().name, location)}-loganalytics' 277 | scope: rg 278 | params: { 279 | name: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 280 | location: location 281 | tags: tags 282 | dataRetention: 30 283 | } 284 | } 285 | 286 | module monitoring 'br/public:avm/res/insights/component:0.6.0' = { 287 | name: '${uniqueString(deployment().name, location)}-appinsights' 288 | scope: rg 289 | params: { 290 | name: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 291 | location: location 292 | tags: tags 293 | workspaceResourceId: logAnalytics.outputs.resourceId 294 | disableLocalAuth: true 295 | } 296 | } 297 | 298 | // Creates Azure API Management (APIM) service to mediate the requests between the frontend and the backend API 299 | module apim 'br/public:avm/res/api-management/service:0.2.0' = if (useAPIM) { 300 | name: 'apim-deployment' 301 | scope: rg 302 | params: { 303 | name: !empty(apimServiceName) ? apimServiceName : '${abbrs.apiManagementService}${resourceToken}' 304 | publisherEmail: 'noreply@microsoft.com' 305 | publisherName: 'n/a' 306 | location: location 307 | tags: tags 308 | sku: apimSku 309 | skuCount: 0 310 | zones: [] 311 | customProperties: {} 312 | loggers: [ 313 | { 314 | name: 'app-insights-logger' 315 | credentials: { 316 | instrumentationKey: monitoring.outputs.instrumentationKey 317 | } 318 | loggerDescription: 'Logger to Azure Application Insights' 319 | isBuffered: false 320 | loggerType: 'applicationInsights' 321 | targetResourceId: monitoring.outputs.resourceId 322 | } 323 | ] 324 | } 325 | } 326 | 327 | //Configures the API settings for an api app within the Azure API Management (APIM) service. 328 | module apimApi 'br/public:avm/ptn/azd/apim-api:0.1.0' = if (useAPIM) { 329 | name: 'apim-api-deployment' 330 | scope: rg 331 | params: { 332 | apiBackendUrl: api.outputs.SERVICE_API_URI 333 | apiDescription: 'This is a simple Todo API' 334 | apiDisplayName: 'Simple Todo API' 335 | apiName: 'todo-api' 336 | apiPath: 'todo' 337 | name: useAPIM ? apim.outputs.name : '' 338 | webFrontendUrl: webUri 339 | location: location 340 | apiAppName: api.outputs.SERVICE_API_NAME 341 | } 342 | } 343 | 344 | // Data outputs 345 | output AZURE_SQL_CONNECTION_STRING_KEY string = 'Server=${db.outputs.fullyQualifiedDomainName}; Database=${db.outputs.databaseName}; Authentication=Active Directory Default; User Id=${apiUserAssignedIdentity.outputs.clientId}; TrustServerCertificate=True' 346 | output AZURE_SQL_SERVER_NAME string = db.outputs.fullyQualifiedDomainName 347 | output AZURE_SQL_DATABASE_NAME string = db.outputs.databaseName 348 | output USER_ASSIGNED_IDENTITY_CLIENT_ID string = apiUserAssignedIdentity.outputs.clientId 349 | 350 | // App outputs 351 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.connectionString 352 | output AZURE_KEY_VAULT_ENDPOINT string = enableSQLScripts ? db.outputs.keyVaultUri : '' 353 | output AZURE_KEY_VAULT_NAME string = enableSQLScripts ? db.outputs.keyVaultName : '' 354 | output AZURE_LOCATION string = location 355 | output AZURE_TENANT_ID string = tenant().tenantId 356 | output API_BASE_URL string = useAPIM ? apimApi.outputs.serviceApiUri : api.outputs.SERVICE_API_URI 357 | output REACT_APP_WEB_BASE_URL string = webUri 358 | output USE_APIM bool = useAPIM 359 | output SERVICE_API_ENDPOINTS array = useAPIM ? [ apimApi.outputs.serviceApiUri, api.outputs.SERVICE_API_URI ]: [] 360 | -------------------------------------------------------------------------------- /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 | "sqlAdminPassword": { 15 | "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} sqlAdminPassword)" 16 | }, 17 | "appUserPassword": { 18 | "value": "$(secretOrRandomPassword ${AZURE_KEY_VAULT_NAME} appUserPassword)" 19 | }, 20 | "useAPIM": { 21 | "value": "${USE_APIM=false}" 22 | }, 23 | "apimSku": { 24 | "value": "${APIM_SKU=Consumption}" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /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 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml -------------------------------------------------------------------------------- /src/api/ListsFunctions.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Microsoft.Azure.Functions.Worker; 3 | using Microsoft.Azure.Functions.Worker.Http; 4 | using Microsoft.Extensions.Logging; 5 | using System.Text.Json; 6 | 7 | namespace SimpleTodo.Api; 8 | public class ListsFunctions 9 | { 10 | private readonly ILogger logger; 11 | private readonly ListsRepository repository; 12 | 13 | public ListsFunctions(ILoggerFactory _loggerFactory, ListsRepository _repository) 14 | { 15 | logger = _loggerFactory.CreateLogger(); 16 | repository = _repository; 17 | } 18 | 19 | [Function("GetLists")] 20 | public async Task GetLists( 21 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "lists")] 22 | HttpRequestData req, int? skip = null, int? batchSize = null) 23 | { 24 | var response = req.CreateResponse(HttpStatusCode.OK); 25 | var lists = await repository.GetListsAsync(skip, batchSize); 26 | response.WriteString(JsonSerializer.Serialize(lists)); 27 | return response; 28 | } 29 | 30 | [Function("CreateList")] 31 | public async Task CreateList( 32 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "lists")] HttpRequestData req, string name, string? description = "") 33 | { 34 | var response = req.CreateResponse(HttpStatusCode.Created); 35 | var todoList = new TodoList(name) 36 | { 37 | Description = description 38 | }; 39 | await repository.AddListAsync(todoList); 40 | response.WriteString(JsonSerializer.Serialize(todoList)); 41 | return response; 42 | } 43 | 44 | [Function("GetList")] 45 | public async Task GetList( 46 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "lists/{listId}")] HttpRequestData req, Guid listId) 47 | { 48 | var response = req.CreateResponse(HttpStatusCode.OK); 49 | var list = await repository.GetListAsync(listId); 50 | if (list == null) 51 | { 52 | return req.CreateResponse(HttpStatusCode.NotFound); 53 | } 54 | response.WriteString(JsonSerializer.Serialize(list)); 55 | return response; 56 | } 57 | 58 | [Function("UpdateList")] 59 | public async Task UpdateList( 60 | [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "lists/{listId}")] HttpRequestData req, Guid listId, string name, string? description = "") 61 | { 62 | var response = req.CreateResponse(HttpStatusCode.OK); 63 | var existingList = await repository.GetListAsync(listId); 64 | if (existingList == null) 65 | { 66 | return req.CreateResponse(HttpStatusCode.NotFound); 67 | } 68 | existingList.Name = name; 69 | existingList.Description = description; 70 | existingList.UpdatedDate = DateTimeOffset.UtcNow; 71 | await repository.SaveChangesAsync(); 72 | response.WriteString(JsonSerializer.Serialize(existingList)); 73 | return response; 74 | } 75 | 76 | [Function("DeleteList")] 77 | public async Task DeleteList( 78 | [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "lists/{listId}")] 79 | HttpRequestData req, Guid listId) 80 | { 81 | var response = req.CreateResponse(HttpStatusCode.NoContent); 82 | if (await repository.GetListAsync(listId) == null) 83 | { 84 | return req.CreateResponse(HttpStatusCode.NotFound); 85 | } 86 | await repository.DeleteListAsync(listId); 87 | return response; 88 | } 89 | 90 | [Function("GetListItems")] 91 | public async Task GetListItems( 92 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "lists/{listId}/items")] 93 | HttpRequestData req, Guid listId, int? skip = null, int? batchSize = null) 94 | { 95 | var response = req.CreateResponse(HttpStatusCode.OK); 96 | if (await repository.GetListAsync(listId) == null) 97 | { 98 | return req.CreateResponse(HttpStatusCode.NotFound); 99 | } 100 | var items = await repository.GetListItemsAsync(listId, skip, batchSize); 101 | response.WriteString(JsonSerializer.Serialize(items)); 102 | return response; 103 | } 104 | 105 | [Function("CreateListItem")] 106 | public async Task CreateListItem( 107 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "lists/{listId}/items")] HttpRequestData req, 108 | Guid listId, string name, string? state = "", string? description = "") 109 | { 110 | var response = req.CreateResponse(HttpStatusCode.Created); 111 | if (await repository.GetListAsync(listId) == null) 112 | { 113 | return req.CreateResponse(HttpStatusCode.NotFound); 114 | } 115 | var newItem = new TodoItem(listId, name) 116 | { 117 | Name = name, 118 | Description = description, 119 | State = (state == null ? "todo" : state), 120 | CreatedDate = DateTimeOffset.UtcNow 121 | }; 122 | await repository.AddListItemAsync(newItem); 123 | response.WriteString(JsonSerializer.Serialize(newItem)); 124 | return response; 125 | } 126 | 127 | [Function("GetListItem")] 128 | public async Task GetListItem( 129 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "lists/{listId}/items/{itemId}")] HttpRequestData req, 130 | Guid itemId, Guid listId) 131 | { 132 | var response = req.CreateResponse(HttpStatusCode.OK); 133 | if (await repository.GetListAsync(listId) == null) 134 | { 135 | return req.CreateResponse(HttpStatusCode.NotFound); 136 | } 137 | var item = await repository.GetListItemAsync(listId, itemId); 138 | response.WriteString(JsonSerializer.Serialize(item)); 139 | return response; 140 | } 141 | 142 | [Function("UpdateListItem")] 143 | public async Task UpdateListItem( 144 | [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "lists/{listId}/items/{itemId}")] 145 | HttpRequestData req, Guid listId, Guid itemId, string name, string? description = "", 146 | string? state = "", string? completedDate = null, string? dueDate = null) 147 | { 148 | var response = req.CreateResponse(HttpStatusCode.OK); 149 | var existingItem = await repository.GetListItemAsync(listId, itemId); 150 | if (existingItem == null) 151 | { 152 | return req.CreateResponse(HttpStatusCode.NotFound); 153 | } 154 | existingItem.Name = name; 155 | existingItem.Description = description; 156 | if (completedDate is not null) 157 | { 158 | existingItem.CompletedDate = DateTimeOffset.Parse(completedDate); 159 | } 160 | if (dueDate is not null) 161 | { 162 | existingItem.DueDate = DateTimeOffset.Parse(dueDate); ; 163 | } 164 | existingItem.State = state; 165 | existingItem.UpdatedDate = DateTimeOffset.UtcNow; 166 | await repository.SaveChangesAsync(); 167 | response.WriteString(JsonSerializer.Serialize(existingItem)); 168 | return response; 169 | } 170 | 171 | [Function("DeleteListItem")] 172 | public async Task DeleteListItem( 173 | [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "lists/{listId}/items/{itemId}")] 174 | HttpRequestData req, Guid itemId, Guid listId) 175 | { 176 | var response = req.CreateResponse(HttpStatusCode.NoContent); 177 | if (await repository.GetListItemAsync(listId, itemId) == null) 178 | { 179 | return req.CreateResponse(HttpStatusCode.NotFound); ; 180 | } 181 | await repository.DeleteListItemAsync(listId, itemId); 182 | return response; 183 | } 184 | 185 | [Function("GetListItemsByState")] 186 | public async Task GetListItemsByState( 187 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "lists/{listId}/state/{state}")] 188 | HttpRequestData req, Guid listId, string state, int? skip = null, int? batchSize = null) 189 | { 190 | var response = req.CreateResponse(HttpStatusCode.OK); 191 | if (await repository.GetListAsync(listId) == null) 192 | { 193 | return req.CreateResponse(HttpStatusCode.NotFound); 194 | } 195 | var items = await repository.GetListItemsByStateAsync(listId, state, skip, batchSize); 196 | response.WriteString(JsonSerializer.Serialize(items)); 197 | return response; 198 | } 199 | } -------------------------------------------------------------------------------- /src/api/ListsRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace SimpleTodo.Api; 4 | 5 | public class ListsRepository 6 | { 7 | 8 | private readonly TodoDb _db; 9 | 10 | public ListsRepository(TodoDb db) 11 | { 12 | _db = db; 13 | } 14 | 15 | public async Task> GetListsAsync(int? skip, int? batchSize) 16 | { 17 | return await ToListAsync(_db.Lists, skip, batchSize); 18 | } 19 | 20 | public async Task GetListAsync(Guid listId) 21 | { 22 | return await _db.Lists.SingleOrDefaultAsync(list => list.Id == listId); 23 | } 24 | 25 | public async Task DeleteListAsync(Guid listId) 26 | { 27 | var list = await GetListAsync(listId); 28 | if (list != null) 29 | { 30 | _db.Lists.Remove(list); 31 | } 32 | 33 | await _db.SaveChangesAsync(); 34 | } 35 | 36 | public async Task AddListAsync(TodoList list) 37 | { 38 | _db.Lists.Add(list); 39 | await _db.SaveChangesAsync(); 40 | } 41 | public async Task SaveChangesAsync() 42 | { 43 | await _db.SaveChangesAsync(); 44 | } 45 | 46 | public async Task> GetListItemsAsync(Guid listId, int? skip, int? batchSize) 47 | { 48 | return await ToListAsync( 49 | _db.Items.Where(i => i.ListId == listId), 50 | skip, 51 | batchSize); 52 | } 53 | 54 | public async Task> GetListItemsByStateAsync(Guid listId, string state, int? skip, int? batchSize) 55 | { 56 | return await ToListAsync( 57 | _db.Items.Where(i => i.ListId == listId && i.State == state), 58 | skip, 59 | batchSize); 60 | } 61 | 62 | public async Task AddListItemAsync(TodoItem item) 63 | { 64 | _db.Items.Add(item); 65 | await _db.SaveChangesAsync(); 66 | } 67 | 68 | public async Task GetListItemAsync(Guid listId, Guid itemId) 69 | { 70 | return await _db.Items.SingleOrDefaultAsync(item => item.Id == itemId && item.ListId == listId); 71 | } 72 | 73 | public async Task DeleteListItemAsync(Guid listId, Guid itemId) 74 | { 75 | var list = await GetListItemAsync(listId, itemId); 76 | if (list != null) 77 | { 78 | _db.Items.Remove(list); 79 | } 80 | 81 | await _db.SaveChangesAsync(); 82 | } 83 | private async Task> ToListAsync(IQueryable queryable, int? skip, int? batchSize) 84 | { 85 | if (skip != null) 86 | { 87 | queryable = queryable.Skip(skip.Value); 88 | } 89 | 90 | if (batchSize != null) 91 | { 92 | queryable = queryable.Take(batchSize.Value); 93 | } 94 | return await queryable.ToListAsync(); 95 | } 96 | } -------------------------------------------------------------------------------- /src/api/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Azure.Identity; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Data.SqlClient; 7 | 8 | namespace SimpleTodo.Api; 9 | class Program 10 | { 11 | static async Task Main(string[] args) 12 | { 13 | var credential = new DefaultAzureCredential(); 14 | var host = new HostBuilder() 15 | .ConfigureFunctionsWorkerDefaults() 16 | // .ConfigureAppConfiguration(config => 17 | // config.AddAzureKeyVault(new Uri(Environment.GetEnvironmentVariable("AZURE_KEY_VAULT_ENDPOINT")!), credential)) 18 | .ConfigureServices((config, services) => 19 | { 20 | services.AddScoped(); 21 | services.AddDbContext(options => 22 | { 23 | // Get connection string from Key Vault 24 | var connectionString = Environment.GetEnvironmentVariable("AZURE_SQL_CONNECTION_STRING_KEY"); 25 | 26 | // The connection string should already be configured for AAD authentication from our Bicep template 27 | options.UseSqlServer(connectionString, sqlOptions => 28 | sqlOptions.EnableRetryOnFailure()); 29 | }); 30 | }) 31 | .Build(); 32 | 33 | await using (var scope = host.Services.CreateAsyncScope()) 34 | { 35 | var db = scope.ServiceProvider.GetRequiredService(); 36 | await db.Database.EnsureCreatedAsync(); 37 | } 38 | await host.RunAsync(); 39 | } 40 | } -------------------------------------------------------------------------------- /src/api/README.md: -------------------------------------------------------------------------------- 1 | # Csharp Azure Functions API -------------------------------------------------------------------------------- /src/api/Todo.Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net8.0 4 | v4 5 | Exe 6 | enable 7 | enable 8 | d350d513-6831-4dde-9f92-2beade8cfd0f 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | PreserveNewest 23 | 24 | 25 | PreserveNewest 26 | Never 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/api/TodoDb.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using SimpleTodo.Api; 3 | 4 | public class TodoDb : DbContext 5 | { 6 | public TodoDb(DbContextOptions options) : base(options) { } 7 | public DbSet Items => Set(); 8 | public DbSet Lists => Set(); 9 | } -------------------------------------------------------------------------------- /src/api/TodoItem.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace SimpleTodo.Api; 5 | 6 | public class TodoItem 7 | { 8 | public TodoItem(Guid listId, string name) 9 | { 10 | ListId = listId; 11 | Name = name; 12 | } 13 | [Key] 14 | [JsonPropertyName("id")] 15 | public Guid? Id { get; set; } 16 | 17 | [JsonPropertyName("listId")] 18 | public Guid ListId { get; set; } 19 | 20 | [JsonPropertyName("name")] 21 | public string Name { get; set; } 22 | 23 | [JsonPropertyName("description")] 24 | public string? Description { get; set; } 25 | 26 | [JsonPropertyName("state")] 27 | public string State { get; set; } = "todo"; 28 | 29 | [JsonPropertyName("dueDate")] 30 | public DateTimeOffset? DueDate { get; set; } 31 | 32 | [JsonPropertyName("completedDate")] 33 | public DateTimeOffset? CompletedDate { get; set; } 34 | 35 | [JsonPropertyName("createdDate")] 36 | public DateTimeOffset? CreatedDate { get; set; } = DateTimeOffset.UtcNow; 37 | 38 | [JsonPropertyName("updatedDate")] 39 | public DateTimeOffset? UpdatedDate { get; set; } 40 | } -------------------------------------------------------------------------------- /src/api/TodoList.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace SimpleTodo.Api; 5 | 6 | public class TodoList 7 | { 8 | public TodoList(string name) 9 | { 10 | Name = name; 11 | } 12 | 13 | [Key] 14 | [JsonPropertyName("id")] 15 | public Guid? Id { get; set; } 16 | [JsonPropertyName("name")] 17 | public string Name { get; set; } 18 | 19 | [JsonPropertyName("description")] 20 | public string? Description { get; set; } 21 | 22 | [JsonPropertyName("createdDate")] 23 | public DateTimeOffset CreatedDate { get; set; } = System.DateTimeOffset.UtcNow; 24 | 25 | [JsonPropertyName("updatedDate")] 26 | public DateTimeOffset? UpdatedDate { get; set; } 27 | } -------------------------------------------------------------------------------- /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 | "extensions": { 12 | "http": { 13 | "routePrefix": "", 14 | "customHeaders": { 15 | "Content-Type": "application/json; charset=utf-8" 16 | } 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/api/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" 6 | }, 7 | "Host": { 8 | "LocalHttpPort": 3100, 9 | "CORS": "*" 10 | } 11 | } -------------------------------------------------------------------------------- /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/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-csharp-sql-swa-func/b8f150807ac07b920f14a5062a48edd479ad5586/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 |