├── .gitignore ├── src ├── api │ ├── todo │ │ ├── __init__.py │ │ ├── models.py │ │ ├── app.py │ │ └── routes.py │ ├── .gitignore │ ├── requirements-test.txt │ ├── .vscode │ │ ├── settings.json │ │ └── launch.json │ ├── Dockerfile │ ├── requirements.txt │ ├── pyproject.toml │ ├── tests │ │ ├── conftest.py │ │ └── test_main.py │ ├── README.md │ └── openapi.yaml └── web │ ├── src │ ├── react-app-env.d.ts │ ├── models │ │ ├── index.ts │ │ ├── todoList.ts │ │ ├── todoItem.ts │ │ └── applicationState.ts │ ├── setupTests.ts │ ├── @types │ │ └── window.d.ts │ ├── services │ │ ├── itemService.ts │ │ ├── listService.ts │ │ ├── telemetryService.ts │ │ └── restService.ts │ ├── components │ │ ├── todoContext.ts │ │ ├── telemetryContext.ts │ │ ├── telemetryWithAppInsights.tsx │ │ ├── telemetry.tsx │ │ ├── todoListMenu.tsx │ │ ├── todoItemDetailPane.tsx │ │ └── todoItemListPane.tsx │ ├── index.css │ ├── reportWebVitals.ts │ ├── config │ │ └── index.ts │ ├── layout │ │ ├── sidebar.tsx │ │ ├── header.tsx │ │ └── layout.tsx │ ├── reducers │ │ ├── listsReducer.ts │ │ ├── selectedItemReducer.ts │ │ ├── index.ts │ │ └── selectedListReducer.ts │ ├── App.css │ ├── index.tsx │ ├── ux │ │ ├── theme.ts │ │ └── styles.ts │ ├── actions │ │ ├── common.ts │ │ ├── actionCreators.ts │ │ ├── listActions.ts │ │ └── itemActions.ts │ ├── App.tsx │ └── pages │ │ └── homePage.tsx │ ├── .dockerignore │ ├── public │ ├── favicon.ico │ └── manifest.json │ ├── nginx │ └── nginx.conf │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── Dockerfile │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── web.config │ ├── index.html │ ├── package.json │ └── README.md ├── tests ├── .gitignore ├── package.json ├── README.md ├── todo.spec.ts ├── playwright.config.ts └── package-lock.json ├── assets ├── urls.png ├── web.png ├── resources.png └── resources-with-apim.png ├── .gitattributes ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── infra ├── modules │ ├── loganalytics │ │ ├── loganalytics_output.tf │ │ ├── loganalytics_variables.tf │ │ └── loganalytics.tf │ ├── apim-api │ │ ├── apim-api_output.tf │ │ ├── apim-api_variables.tf │ │ ├── apim-api.tf │ │ └── apim-api-policy.xml │ ├── appserviceplan │ │ ├── appserviceplan_output.tf │ │ ├── appserviceplan_variables.tf │ │ └── appserviceplan.tf │ ├── keyvault │ │ ├── keyvault_output.tf │ │ ├── keyvault_variables.tf │ │ └── keyvault.tf │ ├── apim │ │ ├── apim_output.tf │ │ ├── apim_variables.tf │ │ └── apim.tf │ ├── cosmos │ │ ├── cosmos_output.tf │ │ ├── cosmos_variables.tf │ │ └── cosmos.tf │ ├── appservicenode │ │ ├── appservicenode_output.tf │ │ ├── appservicenode_variables.tf │ │ └── appservicenode.tf │ ├── appservicepython │ │ ├── appservicepython_output.tf │ │ ├── appservicepython_variables.tf │ │ └── appservicepython.tf │ └── applicationinsights │ │ ├── applicationinsights_output.tf │ │ └── applicationinsights_variables.tf ├── main.tfvars.json ├── provider.tf ├── variables.tf ├── output.tf └── main.tf ├── LICENSE ├── .devcontainer └── devcontainer.json ├── azure.yaml ├── .azdo └── pipelines │ └── azure-dev.yml ├── .github └── workflows │ └── azure-dev.yml ├── README.md └── openapi.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .azure -------------------------------------------------------------------------------- /src/api/todo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/.gitignore: -------------------------------------------------------------------------------- 1 | *env/ 2 | __pycache__ -------------------------------------------------------------------------------- /src/api/requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest>5 2 | pytest-asyncio -------------------------------------------------------------------------------- /src/web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | playwright-report/ 3 | test-results/ 4 | -------------------------------------------------------------------------------- /src/web/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './todoItem'; 2 | export * from './todoList'; -------------------------------------------------------------------------------- /src/web/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.devcontainer 2 | **/build 3 | **/node_modules 4 | README.md 5 | -------------------------------------------------------------------------------- /assets/urls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/HEAD/assets/urls.png -------------------------------------------------------------------------------- /assets/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/HEAD/assets/web.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.{cmd,[cC][mM][dD]} text eol=crlf 3 | *.{bat,[bB][aA][tT]} text eol=crlf -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.azure-dev" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/HEAD/assets/resources.png -------------------------------------------------------------------------------- /src/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/HEAD/src/web/public/favicon.ico -------------------------------------------------------------------------------- /assets/resources-with-apim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/todo-python-mongo-terraform/HEAD/assets/resources-with-apim.png -------------------------------------------------------------------------------- /infra/modules/loganalytics/loganalytics_output.tf: -------------------------------------------------------------------------------- 1 | output "LOGANALYTICS_WORKSPACE_ID" { 2 | value = azurerm_log_analytics_workspace.workspace.id 3 | } -------------------------------------------------------------------------------- /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/appserviceplan/appserviceplan_output.tf: -------------------------------------------------------------------------------- 1 | output "APPSERVICE_PLAN_ID" { 2 | value = azurerm_service_plan.plan.id 3 | sensitive = true 4 | } -------------------------------------------------------------------------------- /infra/modules/keyvault/keyvault_output.tf: -------------------------------------------------------------------------------- 1 | output "AZURE_KEY_VAULT_ENDPOINT" { 2 | value = azurerm_key_vault.kv.vault_uri 3 | sensitive = true 4 | } -------------------------------------------------------------------------------- /src/api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdownlint.config": { 3 | "MD028": false, 4 | "MD025": { 5 | "front_matter_title": "" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/.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 | -------------------------------------------------------------------------------- /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/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/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 | } -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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" {} -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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/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 | } -------------------------------------------------------------------------------- /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 |