├── api ├── host.json ├── AuthorizedUser.cs ├── local.settings.json ├── TodoItem.cs ├── proxies.json ├── api.csproj └── TodoItems.cs ├── .gitignore ├── Screenshot.png ├── ui ├── js │ ├── vars.js │ ├── common.js │ └── mainscript.js └── index.html ├── LICENSE.md ├── TodoServerless.sln └── README.md /api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | bin 3 | obj 4 | *.user 5 | /api/Properties/PublishProfiles 6 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssemyan/TodoServerless/HEAD/Screenshot.png -------------------------------------------------------------------------------- /api/AuthorizedUser.cs: -------------------------------------------------------------------------------- 1 | namespace api 2 | { 3 | class AuthorizedUser 4 | { 5 | public string UniqueName { get; set; } 6 | public string DisplayName { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/js/vars.js: -------------------------------------------------------------------------------- 1 | // Put the urls to your local api and your remote API here 2 | var localUrl = "http://localhost:7071/api/todoitem"; 3 | var remoteUrl = "https:///api/todoitem"; -------------------------------------------------------------------------------- /api/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Host": { 3 | "LocalHttpPort": 7071, 4 | "CORS": "*" 5 | }, 6 | "IsEncrypted": false, 7 | "Values": { 8 | "AzureWebJobsDocumentDBConnectionString": "" 9 | } 10 | } -------------------------------------------------------------------------------- /api/TodoItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace api 4 | { 5 | public class TodoItem 6 | { 7 | public string id { get; set; } 8 | public string ItemName { get; set; } 9 | public string ItemOwner { get; set; } 10 | public DateTime? ItemCreateDate { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /api/proxies.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/proxies", 3 | "proxies": { 4 | "routetobase": { 5 | "matchCondition": { 6 | "route": "/", 7 | "methods": [ 8 | "GET" 9 | ] 10 | }, 11 | "backendUri": "https:////index.html" 12 | }, 13 | "jsproxy": { 14 | "matchCondition": { 15 | "route": "/js/{*restOfPath}", 16 | "methods": [ 17 | "GET" 18 | ] 19 | }, 20 | "backendUri": "https:////js/{restOfPath}" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /api/api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net461 4 | v1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | PreserveNewest 16 | 17 | 18 | PreserveNewest 19 | Never 20 | 21 | 22 | PreserveNewest 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Scott Semyan 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. -------------------------------------------------------------------------------- /ui/js/common.js: -------------------------------------------------------------------------------- 1 | // Global Namespace 2 | var AZ = {}; 3 | 4 | AZ.Ajax = (function () { 5 | "use strict"; 6 | 7 | $(document).ready(function () { 8 | // Add please wait to body and attach to ajax function 9 | var loadingDiv = ""; 10 | $("body").append(loadingDiv); 11 | 12 | $(document).ajaxStart(function () { 13 | $("#ajax_loader").show(); 14 | }); 15 | $(document).ajaxComplete(function () { 16 | $("#ajax_loader").hide(); 17 | }); 18 | }); 19 | 20 | // Errors just get an alert 21 | function handleBasicError(xhr, status, error) { 22 | alert("Error: " + error); 23 | } 24 | 25 | return { 26 | 27 | MakeAjaxCall: function (ajaxType, ajaxUrl, data, successFunc) { 28 | $.ajax({ 29 | type: ajaxType, 30 | url: ajaxUrl, 31 | data: data, 32 | contentType: "application/json; charset=utf-8", 33 | dataType: "json", 34 | success: successFunc, 35 | error: handleBasicError 36 | }); 37 | } 38 | }; 39 | }()); 40 | -------------------------------------------------------------------------------- /ui/js/mainscript.js: -------------------------------------------------------------------------------- 1 | AZ.Documents = (function (ko) { 2 | "use strict"; 3 | 4 | var docViewModel = { 5 | documents: ko.observableArray(), 6 | currentUser: ko.observable() 7 | }; 8 | 9 | var apiUrl = ""; 10 | 11 | function getApiData() { 12 | 13 | // Get list of Documents for the current user (as determined by the API) 14 | AZ.Ajax.MakeAjaxCall("GET", 15 | apiUrl, 16 | null, 17 | function (data) { 18 | docViewModel.documents = ko.observableArray(data.Items); 19 | docViewModel.currentUser(data.UserName); 20 | ko.applyBindings(docViewModel); 21 | }); 22 | } 23 | 24 | // Do this on start 25 | $(document).ready(function () { 26 | 27 | // Set the URL based in whether we are running locally or not 28 | // The URL locations are set in vars.js 29 | // This is a hack and is better solved via a build process 30 | if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { 31 | apiUrl = localUrl; 32 | } else { 33 | apiUrl = remoteUrl; 34 | } 35 | 36 | // Now get the data 37 | getApiData(); 38 | 39 | }); 40 | 41 | return { 42 | model: docViewModel, 43 | 44 | AddEdit: function() { 45 | var newTodo = prompt("Enter new todo item:"); 46 | if (newTodo) { 47 | var newTodoItem = { ItemName: newTodo } 48 | AZ.Ajax.MakeAjaxCall("POST", 49 | apiUrl, 50 | JSON.stringify(newTodoItem), 51 | function (data) { 52 | docViewModel.documents.push(data); 53 | }); 54 | } 55 | }, 56 | 57 | Remove: function(item) { 58 | if (confirm("Mark item '" + item.ItemName + "' as completed?")) { 59 | AZ.Ajax.MakeAjaxCall("DELETE", 60 | apiUrl + "/" + item.id, 61 | null, 62 | function (data) { 63 | docViewModel.documents.remove(item); 64 | }); 65 | } 66 | } 67 | } 68 | }(ko)); -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Serverless Todo App 6 | 7 | 8 | 9 | 10 | 16 | 17 |
18 |

Serverless Todo App

19 |

This simple ToDo application illustrates how to use serverless technology in Azure. This application uses Azure Active Directory for authentication. You can logout here.

20 | 21 |
22 |
23 |

Items ToDo for Unknown

24 |
25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
ItemDate AddedActions
44 | 45 |
No items to display
53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /TodoServerless.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27703.2047 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{E24C65DC-7377-472B-9ABA-BC803B73C61A}") = "ui", "ui\", "{49758ED4-DBD3-43BC-9D7A-03F6A6E39C9F}" 7 | ProjectSection(WebsiteProperties) = preProject 8 | TargetFrameworkMoniker = ".NETFramework,Version%3Dv4.0" 9 | Debug.AspNetCompiler.VirtualPath = "/localhost_50035" 10 | Debug.AspNetCompiler.PhysicalPath = "ui\" 11 | Debug.AspNetCompiler.TargetPath = "PrecompiledWeb\localhost_50035\" 12 | Debug.AspNetCompiler.Updateable = "true" 13 | Debug.AspNetCompiler.ForceOverwrite = "true" 14 | Debug.AspNetCompiler.FixedNames = "false" 15 | Debug.AspNetCompiler.Debug = "True" 16 | Release.AspNetCompiler.VirtualPath = "/localhost_50035" 17 | Release.AspNetCompiler.PhysicalPath = "ui\" 18 | Release.AspNetCompiler.TargetPath = "PrecompiledWeb\localhost_50035\" 19 | Release.AspNetCompiler.Updateable = "true" 20 | Release.AspNetCompiler.ForceOverwrite = "true" 21 | Release.AspNetCompiler.FixedNames = "false" 22 | Release.AspNetCompiler.Debug = "False" 23 | VWDPort = "50035" 24 | SlnRelativePath = "ui\" 25 | EndProjectSection 26 | EndProject 27 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{425F342C-5770-4B9F-AC1A-E9432327E1C9}" 28 | EndProject 29 | Global 30 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 31 | Debug|Any CPU = Debug|Any CPU 32 | Release|Any CPU = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {49758ED4-DBD3-43BC-9D7A-03F6A6E39C9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {49758ED4-DBD3-43BC-9D7A-03F6A6E39C9F}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {49758ED4-DBD3-43BC-9D7A-03F6A6E39C9F}.Release|Any CPU.ActiveCfg = Debug|Any CPU 38 | {49758ED4-DBD3-43BC-9D7A-03F6A6E39C9F}.Release|Any CPU.Build.0 = Debug|Any CPU 39 | {425F342C-5770-4B9F-AC1A-E9432327E1C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {425F342C-5770-4B9F-AC1A-E9432327E1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {425F342C-5770-4B9F-AC1A-E9432327E1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {425F342C-5770-4B9F-AC1A-E9432327E1C9}.Release|Any CPU.Build.0 = Release|Any CPU 43 | EndGlobalSection 44 | GlobalSection(SolutionProperties) = preSolution 45 | HideSolutionNode = FALSE 46 | EndGlobalSection 47 | GlobalSection(ExtensibilityGlobals) = postSolution 48 | SolutionGuid = {02B4DF10-DEE7-4D3D-9C04-E5FD016DE02B} 49 | EndGlobalSection 50 | EndGlobal 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure Functions Todo List Sample 2 | 3 | Note: this application is referenced in the blog post [A Serverless ToDo List](https://ssemyan.github.io/serverless/2018/09/03/serverless-todo-list.html) 4 | 5 | This sample demonstrates a single page application (SPA) hosted on Azure Storage, with an api backend built using Azure Functions. The site uses proxies to route requests for static content to the storage account, CosmosDB to store data, and Azure Active Directory for authentication. 6 | 7 | ![Screenshot](https://github.com/ssemyan/TodoServerless/raw/master/Screenshot.png) 8 | 9 | This code can be run locally (using the Azure Functions CLI and CosmosDB emulator) as well as in Azure. Instructions for both are below. 10 | 11 | The application is a simple Todo list where users can add items "todo". The items are stored in a single CosmosDB document collection but each user can only access their items (user identification is via the claims from the authentication mechanism). 12 | 13 | The SPA is pretty simple with Bootstrap for styles, Knockout.js for data binding, and JQuery for ajax calls. 14 | 15 | Users can add new items to their list, or mark existing items as complete (which deletes them). The inital call to the API pulls the current list of items for the user, along with the user's display name (from the auth claims). 16 | 17 | Note: if you are looking for a Functions 2.0 version, refer to https://github.com/ssemyan/TodoServerless2 (uses Cosmos DB for data storage and implements authentication) or https://github.com/ssemyan/TodoServerless3 (uses Azure Storage Tables for data storage and does not implement authentication). 18 | 19 | ## Setup steps on Localhost 20 | 21 | 1. Install the Azure CLI tools from here: https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local 22 | 23 | 1. If you want to use the emulator for local development, install the CosmosDB emulator from here: https://docs.microsoft.com/en-us/azure/cosmos-db/local-emulator 24 | 25 | 1. In the emulator or in Azure, create a new document collection called 'TodoItems' in a new database called 'ServerlessTodo' and set the Partition Key to '/ItemOwner' 26 | 27 | 1. Update the connection string in **_local.settings.json_** to the one for the emulator or Azure 28 | 29 | 1. Right click the solution, choose properties, and set both the ui and api project to start. 30 | 31 | ## Setup steps on Azure 32 | 33 | 1. Create a new Azure Functions app 34 | 35 | 1. Create a CosmosDB account 36 | 37 | 1. Create a new document collection called 'TodoItems' in a new database called 'ServerlessTodo' and set the Partition Key to '/ItemOwner'. You can do this by using the Data Explorer blade and clicking 'New Container' 38 | 39 | 1. Copy the connetion string for the CosmosDB account (found in the Keys tab) and paste it into a new application setting in the function app called 'AzureWebJobsDocumentDBConnectionString' (this is found in the Configuration settings of the Function App Settings - remember to click 'Save'). 40 | 41 | 1. Update the remoteUrl locations in **_vars.js_** to point to the functions endpoint 42 | 43 | 1. In the storage account for the functions app (or in a different storage account or CDN), upload the static content into a new blob container and mark the container as Public Access Level - Blob. 44 | 45 | 1. Update the **_proxies.json_** file to point to the location where the static files are located 46 | 47 | 1. **Optional** Enable AAD authentication in the Functions App and ensure the option to Login with Azure Active Directory is selected. If you don't do this, you will appear logged in as 'Dev User' and the logout link will not work. 48 | 49 | 1. Publish the function code to the Functions App in Azure (e.g. using Visual Studio) 50 | 51 | 1. Navigate to the site. **Note** because we are using a proxy, the URL for the site is the base URL for the Functions App and you do not need to set CORS (since the URL for the site and API are the same). You can read about how this works in the blog post referenced at the top of this file. 52 | -------------------------------------------------------------------------------- /api/TodoItems.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Security.Claims; 6 | using System.Threading.Tasks; 7 | using Microsoft.Azure.Documents; 8 | using Microsoft.Azure.Documents.Client; 9 | using Microsoft.Azure.WebJobs; 10 | using Microsoft.Azure.WebJobs.Extensions.Http; 11 | using Microsoft.Azure.WebJobs.Host; 12 | 13 | namespace api 14 | { 15 | public static class TodoItems 16 | { 17 | private static AuthorizedUser GetCurrentUserName(TraceWriter log) 18 | { 19 | // On localhost claims will be empty 20 | string name = "Dev User"; 21 | string upn = "dev@localhost"; 22 | 23 | foreach (Claim claim in ClaimsPrincipal.Current.Claims) 24 | { 25 | if (claim.Type == "name") 26 | { 27 | name = claim.Value; 28 | } 29 | if (claim.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn") 30 | { 31 | upn = claim.Value; 32 | } 33 | //Uncomment to print all claims to log output for debugging 34 | //log.Verbose("Claim: " + claim.Type + " Value: " + claim.Value); 35 | } 36 | return new AuthorizedUser() {DisplayName = name, UniqueName = upn }; 37 | } 38 | 39 | // Add new item 40 | [FunctionName("TodoItemAdd")] 41 | public static HttpResponseMessage AddItem( 42 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "todoitem")]HttpRequestMessage req, 43 | [DocumentDB("ServerlessTodo", "TodoItems")] out TodoItem newTodoItem, 44 | TraceWriter log) 45 | { 46 | // Get request body 47 | TodoItem newItem = req.Content.ReadAsAsync().Result; 48 | log.Info("Upserting item: " + newItem.ItemName); 49 | if (string.IsNullOrEmpty(newItem.id)) 50 | { 51 | // New Item so add ID and date 52 | log.Info("Item is new."); 53 | newItem.id = Guid.NewGuid().ToString(); 54 | newItem.ItemCreateDate = DateTime.Now; 55 | newItem.ItemOwner = GetCurrentUserName(log).UniqueName; 56 | } 57 | newTodoItem = newItem; 58 | 59 | return req.CreateResponse(HttpStatusCode.OK, newItem); 60 | } 61 | 62 | // Get all items 63 | [FunctionName("TodoItemGetAll")] 64 | public static HttpResponseMessage GetAll( 65 | [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todoitem")]HttpRequestMessage req, 66 | [DocumentDB("ServerlessTodo", "TodoItems")] DocumentClient client, 67 | TraceWriter log) 68 | { 69 | var currentUser = GetCurrentUserName(log); 70 | log.Info("Getting all Todo items for user: " + currentUser.UniqueName); 71 | 72 | Uri collectionUri = UriFactory.CreateDocumentCollectionUri("ServerlessTodo", "TodoItems"); 73 | 74 | var itemQuery = client.CreateDocumentQuery(collectionUri, new FeedOptions { PartitionKey = new PartitionKey(currentUser.UniqueName) }); 75 | 76 | var ret = new { UserName = currentUser.DisplayName, Items = itemQuery.ToArray() }; 77 | 78 | return req.CreateResponse(HttpStatusCode.OK, ret); 79 | } 80 | 81 | // Delete item by id 82 | [FunctionName("TodoItemDelete")] 83 | public static async Task DeleteItem( 84 | [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "todoitem/{id}")]HttpRequestMessage req, 85 | [DocumentDB("ServerlessTodo", "TodoItems")] DocumentClient client, string id, 86 | TraceWriter log) 87 | { 88 | var currentUser = GetCurrentUserName(log); 89 | log.Info("Deleting document with ID " + id + " for user " + currentUser.UniqueName); 90 | 91 | Uri documentUri = UriFactory.CreateDocumentUri("ServerlessTodo", "TodoItems", id); 92 | 93 | try 94 | { 95 | // Verify the user owns the document and can delete it 96 | await client.DeleteDocumentAsync(documentUri, new RequestOptions() { PartitionKey = new PartitionKey(currentUser.UniqueName) }); 97 | } 98 | catch (DocumentClientException ex) 99 | { 100 | if (ex.StatusCode == HttpStatusCode.NotFound) 101 | { 102 | // Document does not exist, is not owned by the current user, or was already deleted 103 | log.Warning("Document with ID: " + id + " not found."); 104 | } 105 | else 106 | { 107 | // Something else happened 108 | throw ex; 109 | } 110 | } 111 | 112 | return req.CreateResponse(HttpStatusCode.NoContent); 113 | } 114 | } 115 | } 116 | --------------------------------------------------------------------------------