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