├── .gitignore ├── README.md └── csharp └── ExecuteFunction.cs /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pf-af-devfuncs 2 | Azure Functions related to the PlayFab developer experience 3 | 4 | This repository contains various Azure Functions that contribute to the PlayFab developer experience. 5 | Specifically, various implementation of ExecuteFunction, which are used to support local debugging of 6 | Azure Functions when using CloudScript. 7 | 8 | Setting up local debugging involves 2 broad steps; 9 | 10 | * Adding an implementation of ExecuteFunction to your local Azure Functions app 11 | * Adding a settings file to tell the PlayFab SDK to call that local implementation from your game. 12 | 13 | Once those steps are complete, you can run your local Azure Functions app under 14 | the debugger (e.g. in VS Code or Visual Studio), set your breakpoints and run 15 | your game client. 16 | 17 | The rest of this document provides details on the above two steps. 18 | 19 | # Local implementation of ExecuteFunction 20 | 21 | ## For C# Azure Functions apps 22 | 23 | To get the local implementation of ExecuteFunction set up in your C# Azure Functions app, add the [ExecuteFunction.cs](https://github.com/PlayFab/pf-af-devfuncs/blob/master/csharp/ExecuteFunction.cs) file to your local Azure Functions app. 24 | 25 | # Required environment variables for local implementation of ExecuteFunction 26 | 27 | Next, add two settings to your local.settings.json file; 28 | 29 | | Name | Value | 30 | |--|--| 31 | | PLAYFAB_TITLE_ID | Your title ID, in hex form | 32 | | PLAYFAB_DEV_SECRET_KEY | Secret key for your title | 33 | 34 | For example; 35 | 36 | ``` 37 | { 38 | "IsEncrypted": false, 39 | "Values": { 40 | "AzureWebJobsStorage": "...", 41 | "FUNCTIONS_WORKER_RUNTIME": "dotnet", 42 | "PLAYFAB_TITLE_ID": "B55D", 43 | "PLAYFAB_DEV_SECRET_KEY": "AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMM" 44 | } 45 | } 46 | 47 | ``` 48 | 49 | # Configuring PlayFab SDK to call local ExecuteFunction implementation 50 | 51 | To tell the PlayFab SDK to redirect ExecuteFunction API calls to your local implementation, add a file called playfab.local.settings.json to one of two places; 52 | 53 | * The temporary directory on your machine 54 | * TMPDIR environment variable on Linux/Mac 55 | * TEMP environment variable on Windows 56 | * The directory of your game executable. 57 | 58 | The content of the file should be as follows; 59 | 60 | ``` 61 | { "LocalApiServer": "http://localhost:7071/api/" } 62 | ``` 63 | 64 | To stop local redirects and make ExecuteFunction call the PlayFab API server simply delete the playfab.local.settings.json file. 65 | 66 | The above is supported in the following SDKs; 67 | 68 | * [PlayFab C# SDK](https://github.com/PlayFab/CSharpSDK) 69 | * [PlayFab Unity SDK](https://github.com/PlayFab/UnitySDK) 70 | * [Unreal 4 Marketplace PlugIn for PlayFab](https://github.com/PlayFab/UnrealMarketplacePlugin) 71 | 72 | ## Custom route prefixes 73 | 74 | If you use a custom route prefix in your host.json, you will need to change the /api/ part of the file content to match the custom route prefix specified in the host.json. For example, if your host.json specifies a route prefix 75 | of 'cs', then your playfab.local.settings.json should be as follows; 76 | 77 | ``` 78 | { "LocalApiServer": "http://localhost:7071/cs/" } 79 | ``` 80 | 81 | If your host.json specifies an empty custom route prefix, then your playfab.local.settings.jsoon should be as follows; 82 | 83 | ``` 84 | { "LocalApiServer": "http://localhost:7071/" } 85 | ``` -------------------------------------------------------------------------------- /csharp/ExecuteFunction.cs: -------------------------------------------------------------------------------- 1 | // Copyright (C) Microsoft Corporation. All rights reserved. 2 | 3 | namespace PlayFab.AzureFunctions 4 | { 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.Azure.Functions.Worker; 7 | using Microsoft.Extensions.Logging; 8 | using PlayFab.Internal; 9 | using PlayFab.Json; 10 | using PlayFab.ProfilesModels; 11 | using System.Diagnostics; 12 | using System.IO.Compression; 13 | using System.Net; 14 | using System.Net.Http.Headers; 15 | using System.Text; 16 | 17 | public static class LocalExecuteFunction 18 | { 19 | private readonly ILogger _logger; 20 | 21 | public LocalExecuteFunction(ILogger logger) 22 | { 23 | _logger = logger; 24 | } 25 | 26 | private const string DEV_SECRET_KEY = "PLAYFAB_DEV_SECRET_KEY"; 27 | private const string TITLE_ID_KEY = "PLAYFAB_TITLE_ID_KEY"; 28 | private const string CLOUD_NAME = "PLAYFAB_CLOUD_NAME"; 29 | private const string _defaultRoutePrefix = "api"; 30 | private static readonly HttpClient httpClient = new HttpClient(); 31 | 32 | /// 33 | /// A local implementation of the ExecuteFunction feature. Provides the ability to execute an Azure Function with a local URL with respect to the host 34 | /// of the application this function is running in. 35 | /// 36 | /// The HTTP request 37 | /// The function execution result(s) 38 | [Function("ExecuteFunction")] 39 | public async Task ExecuteFunction( 40 | [HttpTrigger(AuthorizationLevel.Function, "post", Route = "CloudScript/ExecuteFunction")] HttpRequest request) 41 | { 42 | // Extract the caller's entity token 43 | string callerEntityToken = request.Headers["X-EntityToken"]; 44 | 45 | // Extract the request body and deserialize 46 | string body = await DecompressHttpBody(request); 47 | var execRequest = PlayFabSimpleJson.DeserializeObject(body); 48 | EntityKey entityKey = null; 49 | 50 | if (execRequest.Entity != null) 51 | { 52 | entityKey = new EntityKey 53 | { 54 | Id = execRequest.Entity?.Id, 55 | Type = execRequest.Entity?.Type 56 | }; 57 | } 58 | 59 | // Create a FunctionContextInternal as the payload to send to the target function 60 | var functionContext = new FunctionContextInternal 61 | { 62 | CallerEntityProfile = await GetEntityProfile(callerEntityToken, entityKey), 63 | TitleAuthenticationContext = new TitleAuthenticationContext 64 | { 65 | Id = Environment.GetEnvironmentVariable(TITLE_ID_KEY, EnvironmentVariableTarget.Process), 66 | EntityToken = await GetTitleEntityToken() 67 | }, 68 | FunctionArgument = execRequest.FunctionParameter 69 | }; 70 | 71 | // Serialize the request to the azure function and add headers 72 | var functionRequestContent = new StringContent(PlayFabSimpleJson.SerializeObject(functionContext)); 73 | functionRequestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 74 | 75 | var azureFunctionUri = ConstructLocalAzureFunctionUri(execRequest.FunctionName, request.Host); 76 | 77 | var sw = new Stopwatch(); 78 | sw.Start(); 79 | 80 | // Execute the local azure function 81 | using (var functionResponseMessage = 82 | await httpClient.PostAsync(azureFunctionUri, functionRequestContent)) 83 | { 84 | sw.Stop(); 85 | long executionTime = sw.ElapsedMilliseconds; 86 | 87 | if (!functionResponseMessage.IsSuccessStatusCode) 88 | { 89 | throw new Exception($"An error occured while executing the target function locally: FunctionName: {execRequest.FunctionName}, HTTP Status Code: {functionResponseMessage.StatusCode}."); 90 | } 91 | 92 | // Extract the response content 93 | using (var functionResponseContent = functionResponseMessage.Content) 94 | { 95 | // Prepare a response to reply back to client with and include function execution results 96 | var functionResult = new ExecuteFunctionResult 97 | { 98 | FunctionName = execRequest.FunctionName, 99 | FunctionResult = await ExtractFunctionResult(functionResponseContent), 100 | ExecutionTimeMilliseconds = (int)executionTime, 101 | FunctionResultTooLarge = false 102 | }; 103 | 104 | // Reply back to client with final results 105 | var output = new PlayFabJsonSuccess 106 | { 107 | code = 200, 108 | status = "OK", 109 | data = functionResult 110 | }; 111 | // Serialize the output and return it 112 | var outputStr = PlayFabSimpleJson.SerializeObject(output); 113 | 114 | return new HttpResponseMessage 115 | { 116 | Content = CompressResponseBody(output, request), 117 | StatusCode = HttpStatusCode.OK 118 | }; 119 | } 120 | } 121 | } 122 | 123 | /// 124 | /// Fetch's an entity's profile from the PlayFab server 125 | /// 126 | /// The entity token of the entity profile being fetched 127 | /// The entity's profile 128 | private static async Task GetEntityProfile(string callerEntityToken, EntityKey entity) 129 | { 130 | // Construct the PlayFabAPI URL for GetEntityProfile 131 | var getProfileUrl = GetServerApiUri("/Profile/GetProfile"); 132 | 133 | // Create the get entity profile request 134 | var profileRequest = new GetEntityProfileRequest 135 | { 136 | Entity = entity 137 | }; 138 | 139 | // Prepare the request headers 140 | var profileRequestContent = new StringContent(PlayFabSimpleJson.SerializeObject(profileRequest)); 141 | profileRequestContent.Headers.Add("X-EntityToken", callerEntityToken); 142 | profileRequestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 143 | 144 | PlayFabJsonSuccess getProfileResponseSuccess = null; 145 | GetEntityProfileResponse getProfileResponse = null; 146 | 147 | // Execute the get entity profile request 148 | using (var profileResponseMessage = 149 | await httpClient.PostAsync(getProfileUrl, profileRequestContent)) 150 | { 151 | using (var profileResponseContent = profileResponseMessage.Content) 152 | { 153 | string profileResponseString = await profileResponseContent.ReadAsStringAsync(); 154 | 155 | // Deserialize the http response 156 | getProfileResponseSuccess = 157 | PlayFabSimpleJson.DeserializeObject>(profileResponseString); 158 | 159 | // Extract the actual get profile response from the deserialized http response 160 | getProfileResponse = getProfileResponseSuccess?.data; 161 | } 162 | } 163 | 164 | // If response object was not filled it means there was an error 165 | if (getProfileResponseSuccess?.data == null || getProfileResponseSuccess?.code != 200) 166 | { 167 | throw new Exception($"Failed to get Entity Profile: code: {getProfileResponseSuccess?.code}"); 168 | } 169 | 170 | return getProfileResponse.Profile; 171 | } 172 | 173 | /// 174 | /// Grabs the developer secret key from the environment variable (expected to be set) and uses it to 175 | /// ask the PlayFab server for a title entity token. 176 | /// 177 | /// The title's entity token 178 | private static async Task GetTitleEntityToken() 179 | { 180 | var titleEntityTokenRequest = new PlayFab.AuthenticationModels.GetEntityTokenRequest(); 181 | 182 | var getEntityTokenUrl = GetServerApiUri("/Authentication/GetEntityToken"); 183 | 184 | // Grab the developer secret key from the environment variables (app settings) to use as header for GetEntityToken 185 | var secretKey = Environment.GetEnvironmentVariable(DEV_SECRET_KEY, EnvironmentVariableTarget.Process); 186 | 187 | if (string.IsNullOrEmpty(secretKey)) 188 | { 189 | // Environment variable was not set on the app 190 | throw new Exception("Could not fetch the developer secret key from the environment. Please set \"PLAYFAB_DEV_SECRET_KEY\" in your app's local.settings.json file."); 191 | } 192 | 193 | var titleEntityTokenRequestContent = new StringContent(PlayFabSimpleJson.SerializeObject(titleEntityTokenRequest)); 194 | titleEntityTokenRequestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 195 | titleEntityTokenRequestContent.Headers.Add("X-SecretKey", secretKey); 196 | using (var titleEntityTokenResponseMessage = 197 | await httpClient.PostAsync(getEntityTokenUrl, titleEntityTokenRequestContent)) 198 | { 199 | using (var titleEntityTokenResponseContent = titleEntityTokenResponseMessage.Content) 200 | { 201 | string titleEntityTokenResponseString = await titleEntityTokenResponseContent.ReadAsStringAsync(); 202 | 203 | // Deserialize the http response 204 | var titleEntityTokenResponseSuccess = 205 | PlayFabSimpleJson.DeserializeObject>(titleEntityTokenResponseString); 206 | 207 | // Extract the actual get title entity token header 208 | var titleEntityTokenResponse = titleEntityTokenResponseSuccess.data; 209 | 210 | return titleEntityTokenResponse.EntityToken; 211 | } 212 | } 213 | } 214 | 215 | /// 216 | /// Constructs a function's local url given its name and the current function app's host. Assumes that the 217 | /// function is located in the same function app as the application host provided. 218 | /// 219 | /// The name of the function to construct a URL for 220 | /// The function's application host 221 | /// The function's URI 222 | private static string ConstructLocalAzureFunctionUri(string functionName, HostString appHost) 223 | { 224 | // Assemble the target function's path in the current App 225 | string routePrefix = GetHostRoutePrefix(); 226 | string functionPath = routePrefix != null ? routePrefix + "/" + functionName 227 | : functionName; 228 | 229 | // Build URI of Azure Function based on current host 230 | var uriBuilder = new UriBuilder 231 | { 232 | Host = appHost.Host, 233 | Port = appHost.Port ?? 80, 234 | Path = functionPath 235 | }; 236 | 237 | return uriBuilder.Uri.AbsoluteUri; 238 | } 239 | 240 | private static async Task ExtractFunctionResult(HttpContent content) 241 | { 242 | string responseContent = await content.ReadAsStringAsync(); 243 | 244 | if (!string.IsNullOrWhiteSpace(responseContent)) 245 | { 246 | // JSON object or array 247 | if (responseContent.StartsWith("{") || responseContent.StartsWith("[")) 248 | { 249 | return PlayFabSimpleJson.DeserializeObject(responseContent); 250 | } 251 | // JSON number 252 | else if (float.TryParse(responseContent, out float f)) 253 | { 254 | return f; 255 | } 256 | // JSON true or false 257 | else if (bool.TryParse(responseContent, out bool b)) 258 | { 259 | return b; 260 | } 261 | else // JSON string 262 | { 263 | return responseContent; 264 | } 265 | } 266 | 267 | return null; 268 | } 269 | 270 | 271 | private static string GetServerApiUri(string endpoint) 272 | { 273 | var sb = new StringBuilder(); 274 | 275 | // Append the title name if applicable 276 | string title = Environment.GetEnvironmentVariable(TITLE_ID_KEY, EnvironmentVariableTarget.Process); 277 | if (!string.IsNullOrEmpty(title)) 278 | { 279 | sb.Append(title).Append("."); 280 | } 281 | // Append the cloud name if applicable 282 | string cloud = Environment.GetEnvironmentVariable(CLOUD_NAME, EnvironmentVariableTarget.Process); 283 | if (!string.IsNullOrEmpty(cloud)) 284 | { 285 | sb.Append(cloud).Append("."); 286 | } 287 | // Append base PF API address 288 | sb.Append("playfabapi.com"); 289 | 290 | var uriBuilder = new UriBuilder 291 | { 292 | Scheme = "https", 293 | Host = sb.ToString(), 294 | Path = endpoint 295 | }; 296 | 297 | return uriBuilder.Uri.AbsoluteUri; 298 | } 299 | 300 | #region Utility Functions 301 | private static string ReadAllFileText(string filename) 302 | { 303 | var sb = new StringBuilder(); 304 | 305 | if (!File.Exists(filename)) 306 | { 307 | return string.Empty; 308 | } 309 | 310 | if (sb == null) 311 | { 312 | sb = new StringBuilder(); 313 | } 314 | sb.Length = 0; 315 | 316 | using (var fs = new FileStream(filename, FileMode.Open)) 317 | { 318 | using (var br = new BinaryReader(fs)) 319 | { 320 | while (br.BaseStream.Position != br.BaseStream.Length) 321 | { 322 | sb.Append(br.ReadChar()); 323 | } 324 | } 325 | } 326 | 327 | return sb.ToString(); 328 | } 329 | 330 | private static string GetHostRoutePrefix() 331 | { 332 | string hostFileContent = null; 333 | string currDir = Directory.GetCurrentDirectory(); 334 | string currDirHostFile = Path.Combine(currDir, "host.json"); 335 | 336 | if (File.Exists(currDirHostFile)) 337 | { 338 | hostFileContent = ReadAllFileText(currDirHostFile); 339 | } 340 | 341 | var hostModel = PlayFabSimpleJson.DeserializeObject(hostFileContent); 342 | return hostModel?.extensions?.http?.routePrefix ?? _defaultRoutePrefix; 343 | } 344 | 345 | private static async Task DecompressHttpBody(HttpRequest request) 346 | { 347 | string encoding = request.Headers["Content-Encoding"]; 348 | 349 | // Compression was not present and hence attempt to simply read out the body provided 350 | if (string.IsNullOrWhiteSpace(encoding)) 351 | { 352 | using (var reader = new StreamReader(request.Body)) 353 | { 354 | return await reader.ReadToEndAsync(); 355 | } 356 | } 357 | else if (!encoding.ToLower().Equals("gzip")) 358 | { 359 | // Only GZIP decompression supported 360 | throw new Exception($"Unkown compression used on body. Content-Encoding header value: {encoding}. Expecting none or GZIP"); 361 | } 362 | 363 | var responseBytes = StreamToBytes(request.Body); 364 | // Attempt to decompress the GZIP compressed request body 365 | using (Stream responseStream = new MemoryStream(responseBytes)) 366 | { 367 | using (var gZipStream = new GZipStream(responseStream, CompressionMode.Decompress, false)) 368 | { 369 | byte[] buffer = new byte[4 * 1024]; 370 | using (var output = new MemoryStream()) 371 | { 372 | int read; 373 | while ((read = gZipStream.Read(buffer, 0, buffer.Length)) > 0) 374 | { 375 | output.Write(buffer, 0, read); 376 | } 377 | output.Seek(0, SeekOrigin.Begin); 378 | return await new StreamReader(output).ReadToEndAsync(); 379 | } 380 | } 381 | } 382 | } 383 | 384 | private static HttpContent CompressResponseBody(object responseObject, HttpRequest request) 385 | { 386 | string responseJson = PlayFabSimpleJson.SerializeObject(responseObject); 387 | var responseBytes = Encoding.UTF8.GetBytes(responseJson); 388 | 389 | // Get all accepted encodings, 390 | string encodingsString = request.Headers["Accept-Encoding"]; 391 | 392 | // If client doesn't specify accepted encodings, assume identity and respond decompressed 393 | if (string.IsNullOrEmpty(encodingsString)) 394 | { 395 | return new ByteArrayContent(responseBytes); 396 | } 397 | 398 | List encodings = encodingsString.Replace(" ", String.Empty).Split(',').ToList(); 399 | encodings.ForEach(encoding => encoding.ToLower()); 400 | 401 | // If client accepts identity explicitly, respond decompressed 402 | if (encodings.Contains("identity", StringComparer.OrdinalIgnoreCase)) 403 | { 404 | return new ByteArrayContent(responseBytes); 405 | } 406 | 407 | // If client accepts gzip, compress 408 | if (encodings.Contains("gzip", StringComparer.OrdinalIgnoreCase)) 409 | { 410 | using (var stream = new MemoryStream()) 411 | { 412 | using (var gZipStream = new GZipStream(stream, CompressionLevel.Fastest, false)) 413 | { 414 | gZipStream.Write(responseBytes, 0, responseBytes.Length); 415 | } 416 | responseBytes = stream.ToArray(); 417 | } 418 | var content = new ByteArrayContent(responseBytes); 419 | content.Headers.ContentEncoding.Add("gzip"); 420 | return content; 421 | } 422 | 423 | // If neither identity or gzip, throw error: we support gzip only right now 424 | throw new Exception($"Unknown compression requested for response. The \"Accept-Encoding\" haeder values was: ${encodingsString}. Only \"Identity\" and \"GZip\" are supported right now."); 425 | } 426 | 427 | private static byte[] StreamToBytes(Stream input) 428 | { 429 | input.Seek(0, SeekOrigin.Begin); 430 | byte[] buffer = new byte[4 * 1024]; 431 | using (var output = new MemoryStream()) 432 | { 433 | int read; 434 | while ((read = input.Read(buffer, 0, buffer.Length)) > 0) 435 | { 436 | output.Write(buffer, 0, read); 437 | } 438 | return output.ToArray(); 439 | } 440 | } 441 | #endregion 442 | } 443 | 444 | #region Models 445 | public class TitleAuthenticationContext 446 | { 447 | public string Id; 448 | public string EntityToken; 449 | } 450 | 451 | public class FunctionContextInternal : FunctionContextInternal 452 | { 453 | } 454 | 455 | public class FunctionContextInternal 456 | { 457 | public TitleAuthenticationContext TitleAuthenticationContext { get; set; } 458 | public EntityProfileBody CallerEntityProfile { get; set; } 459 | public TFunctionArgument FunctionArgument { get; set; } 460 | } 461 | 462 | public class ExecuteFunctionRequest : PlayFabRequestCommon 463 | { 464 | public PlayFab.ClientModels.EntityKey Entity { get; set; } 465 | 466 | public string FunctionName { get; set; } 467 | 468 | public object FunctionParameter { get; set; } 469 | 470 | public bool? GeneratePlayStreamEvent { get; set; } 471 | } 472 | 473 | public class ExecuteFunctionResult : PlayFabResultCommon 474 | { 475 | public int ExecutionTimeMilliseconds; 476 | public string FunctionName; 477 | public object FunctionResult; 478 | public bool? FunctionResultTooLarge; 479 | } 480 | 481 | public class HostJsonModel 482 | { 483 | public string version { get; set; } 484 | public HostJsonExtensionsModel extensions { get; set; } 485 | 486 | public class HostJsonExtensionsModel 487 | { 488 | public HostJsonHttpModel http { get; set; } 489 | } 490 | 491 | public class HostJsonHttpModel 492 | { 493 | public string routePrefix { get; set; } 494 | } 495 | } 496 | #endregion 497 | } --------------------------------------------------------------------------------