├── DatasetteOnAzure ├── __init__.py └── function.json ├── README.md ├── global-power-plants.db ├── host.json ├── local.settings.json └── requirements.txt /DatasetteOnAzure/__init__.py: -------------------------------------------------------------------------------- 1 | import azure.functions as func 2 | import sqlite3 3 | from datasette.app import Datasette 4 | 5 | app = Datasette( 6 | [], 7 | ["global-power-plants.db"], 8 | cors=True, 9 | ).app() 10 | 11 | 12 | def main(req: func.HttpRequest, context: func.Context) -> func.HttpResponse: 13 | return AsgiMiddleware(app).handle(req, context) 14 | 15 | 16 | # Copyright (c) Microsoft Corporation. All rights reserved. 17 | # Licensed under the MIT License. 18 | 19 | import asyncio 20 | from typing import Callable, Dict, List, Tuple, Optional, Any 21 | from io import StringIO 22 | import logging 23 | from os import linesep 24 | from wsgiref.headers import Headers 25 | 26 | from azure.functions._abc import Context 27 | from azure.functions._http import HttpRequest, HttpResponse 28 | from azure.functions._http_wsgi import WsgiRequest 29 | 30 | 31 | class AsgiRequest(WsgiRequest): 32 | _environ_cache: Optional[Dict[str, Any]] = None 33 | 34 | def __init__(self, func_req: HttpRequest, func_ctx: Optional[Context] = None): 35 | self.asgi_version = "2.1" 36 | self.asgi_spec_version = "2.1" 37 | self._headers = func_req.headers 38 | super().__init__(func_req, func_ctx) 39 | 40 | def _get_encoded_http_headers(self) -> List[Tuple[bytes, bytes]]: 41 | return [(k.encode("utf8"), v.encode("utf8")) for k, v in self._headers.items()] 42 | 43 | def to_asgi_http_scope(self): 44 | return { 45 | "type": "http", 46 | "asgi.version": self.asgi_version, 47 | "asgi.spec_version": self.asgi_spec_version, 48 | "http_version": "1.1", 49 | "method": self.request_method, 50 | "scheme": "https", 51 | "path": self.path_info, 52 | "raw_path": self.path_info.encode("utf-8"), 53 | "query_string": self.query_string.encode("utf-8"), 54 | "root_path": self.script_name, 55 | "headers": self._get_encoded_http_headers(), 56 | "server": (self.server_name, self.server_port), 57 | } 58 | # Notes, missing client name, port 59 | 60 | 61 | class AsgiResponse: 62 | def __init__(self): 63 | self._status_code = 0 64 | self._headers = {} 65 | self._buffer: List[bytes] = [] 66 | self._request_body = b"" 67 | 68 | @classmethod 69 | async def from_app(cls, app, scope: Dict[str, Any], body: bytes) -> "AsgiResponse": 70 | res = cls() 71 | res._request_body = body 72 | await app(scope, res._receive, res._send) 73 | return res 74 | 75 | def to_func_response(self) -> HttpResponse: 76 | lowercased_headers = {k.lower(): v for k, v in self._headers.items()} 77 | return HttpResponse( 78 | body=b"".join(self._buffer), 79 | status_code=self._status_code, 80 | headers=self._headers, 81 | mimetype=lowercased_headers.get("content-type"), 82 | charset=lowercased_headers.get("content-encoding"), 83 | ) 84 | 85 | def _handle_http_response_start(self, message: Dict): 86 | self._headers = Headers([(k.decode(), v.decode()) for k, v in message["headers"]]) # type: ignore 87 | self._status_code = message["status"] 88 | 89 | def _handle_http_response_body(self, message: Dict): 90 | self._buffer.append(message["body"]) 91 | # TODO : Handle more_body flag 92 | 93 | async def _receive(self): 94 | return { 95 | "type": "http.request", 96 | "body": self._request_body, 97 | "more_body": False, 98 | } 99 | 100 | async def _send(self, message): 101 | logging.debug(f"Received {message} from ASGI worker.") 102 | if message["type"] == "http.response.start": 103 | self._handle_http_response_start(message) 104 | elif message["type"] == "http.response.body": 105 | self._handle_http_response_body(message) 106 | elif message["type"] == "http.disconnect": 107 | pass # Nothing todo here 108 | 109 | 110 | class AsgiMiddleware: 111 | def __init__(self, app): 112 | logging.debug("Instantiating ASGI middleware.") 113 | self._app = app 114 | self._asgi_error_buffer = StringIO() 115 | self.loop = asyncio.new_event_loop() 116 | logging.debug("asyncio event loop initialized.") 117 | 118 | # Usage 119 | # main = func.AsgiMiddleware(app).main 120 | @property 121 | def main(self) -> Callable[[HttpRequest, Context], HttpResponse]: 122 | return self._handle 123 | 124 | # Usage 125 | # return func.AsgiMiddleware(app).handle(req, context) 126 | def handle( 127 | self, req: HttpRequest, context: Optional[Context] = None 128 | ) -> HttpResponse: 129 | logging.info(f"Handling {req.url} as ASGI request.") 130 | return self._handle(req, context) 131 | 132 | def _handle(self, req: HttpRequest, context: Optional[Context]) -> HttpResponse: 133 | asgi_request = AsgiRequest(req, context) 134 | asyncio.set_event_loop(self.loop) 135 | scope = asgi_request.to_asgi_http_scope() 136 | asgi_response = self.loop.run_until_complete( 137 | AsgiResponse.from_app(self._app, scope, req.get_body()) 138 | ) 139 | self._handle_errors() 140 | return asgi_response.to_func_response() 141 | 142 | def _handle_errors(self): 143 | if self._asgi_error_buffer.tell() > 0: 144 | self._asgi_error_buffer.seek(0) 145 | error_message = linesep.join(self._asgi_error_buffer.readline()) 146 | raise Exception(error_message) 147 | -------------------------------------------------------------------------------- /DatasetteOnAzure/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptFile": "__init__.py", 3 | "bindings": [ 4 | { 5 | "authLevel": "Anonymous", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "name": "req", 9 | "route": "{*route}", 10 | "methods": [ 11 | "get", 12 | "post" 13 | ] 14 | }, 15 | { 16 | "type": "http", 17 | "direction": "out", 18 | "name": "$return" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deploying Datasette using Azure Functions 2 | 3 | This repository shows an example of Datasette deployed using Azure Functions. 4 | 5 | https://azure-functions-datasette.azurewebsites.net/ 6 | 7 | It uses a slightly modified version of the ASGI proof of concept wrapper created by Anthony Shaw in [this GitHub issues thread](https://github.com/Azure/azure-functions-python-library/issues/75#issuecomment-808553496). 8 | 9 | To deploy you'll need an Azure account, plus the `az` and `func` command-line tools. I installed these following the instructions in [this tutorial](https://docs.microsoft.com/en-us/azure/azure-functions/create-first-function-cli-python?tabs=azure-cli%2Cbash%2Cbrowser) - specifically [this](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=macos%2Ccsharp%2Cbash#v2) and [this](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli). 10 | 11 | You'll need to create a resource group, a storage account and a function app. 12 | 13 | I found that the resource group needed to be in `westeurope` for functions to work correctly. 14 | 15 | Creating the resource group: 16 | 17 | ``` 18 | az group create \ 19 | --name datasette-rg \ 20 | --location westeurope 21 | ``` 22 | Creating the storage account: 23 | ``` 24 | az storage account create \ 25 | --name datasettestorage \ 26 | --location westeurope \ 27 | --resource-group datasette-rg \ 28 | --sku Standard_LRS 29 | ``` 30 | Creating the function app. The name here is the name that will be exposed in the default URL, so it needs to be globally unique - unlike the storage account and resource group which only have to be unique within your Azure account. 31 | ``` 32 | az functionapp create \ 33 | --resource-group datasette-rg \ 34 | --consumption-plan-location westeurope \ 35 | --runtime python \ 36 | --runtime-version 3.8 \ 37 | --functions-version 3 \ 38 | --storage-account datasettestorage \ 39 | --os-type linux \ 40 | --name azure-functions-datasette 41 | ``` 42 | Finally, deploy the application like so: 43 | ``` 44 | func azure functionapp publish azure-functions-datasette 45 | ``` 46 | -------------------------------------------------------------------------------- /global-power-plants.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/azure-functions-datasette/a0b290b21361f887d7e888fdf8710478da0ae47e/global-power-plants.db -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[1.*, 2.0.0)" 14 | }, 15 | "extensions": { 16 | "http": { 17 | "routePrefix": "" 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "python", 5 | "AzureWebJobsStorage": "" 6 | } 7 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Do not include azure-functions-worker as it may conflict with the Azure Functions platform 2 | azure-functions 3 | datasette 4 | datasette-debug-asgi 5 | datasette-cluster-map 6 | --------------------------------------------------------------------------------