From e8b77e4e5b9ed5c01eb8a05417441c53719b682b Mon Sep 17 00:00:00 2001 From: Steve Purcell Date: Mon, 19 Oct 2026 15:33:19 +0100 Subject: [PATCH] Renormalize line endings for files with CRLF matching .gitattributes patterns --- .../dotnet/src/GreetingFunctions.cs | 1106 ++++++------ .../dotnet/src/Program.cs | 14 +- .../dotnet/src/sample.csproj | 60 +- .../dotnet/src/sample/GameSessionManager.cs | 1554 ++++++++--------- .../dotnet/src/sample/Program.cs | 14 +- .../dotnet/src/sample/sample.csproj | 64 +- 6 files changed, 1406 insertions(+), 1406 deletions(-) diff --git a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs index bbe47f8..3a3c86b 100644 --- a/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs +++ b/samples/function-app-service-bus/dotnet/src/GreetingFunctions.cs @@ -1,554 +1,554 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Azure.Identity; -using Azure.Messaging.ServiceBus; -using Azure.Messaging.ServiceBus.Administration; - -namespace LocalStack.Azure.Samples; - -/// -/// A simple Azure Function that processes Service Bus messages and responds with a greeting. -/// -public class HelloWorld -{ - // Instance field for logging - keeps proper Azure Functions execution context - private readonly ILogger _logger; - - // Static configuration values - initialized once per application lifetime - private static string? _connectionString; - private static string? _clientId; - private static string? _fullyQualifiedNamespace; - private static bool _hasConnectionString; - private static bool _hasClientId; - private static bool _hasFullyQualifiedNamespace; - private static string? _inputQueueName; - private static string? _outputQueueName; - private static bool _configurationValid = false; - private static string[]? _names; - - // Greeting templates used by GetGreeting to produce varied responses - private static readonly string[] _greetingTemplates = new[] - { - "Hello {0}, how are you?", - "Hi {0}, great to see you!", - "Hey {0}, hope you're having a wonderful day!", - "Good day {0}, welcome aboard!", - "Greetings {0}, nice to meet you!", - "Howdy {0}, what's going on?", - "Welcome {0}, glad you're here!", - "Salutations {0}, how's everything going?" - }; - - private static readonly Random _random = new(); - - // Circular buffers for message history across all functions - private const int MaxHistory = 100; - private static readonly object _historyLock = new(); - private static readonly CircularBuffer _requesterSent = new(MaxHistory); - private static readonly CircularBuffer _handlerReceived = new(MaxHistory); - private static readonly CircularBuffer _handlerSent = new(MaxHistory); - private static readonly CircularBuffer _consumerReceived = new(MaxHistory); - - // Static initialization - runs once per application lifetime - private static readonly Lazy _initialized = new Lazy(() => { Initialize(); return true; }); - - /// - /// Initializes a new instance of the class. - /// - /// The logger factory used to create loggers for this class. - public HelloWorld(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - /// - /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). - /// This method runs exactly once per application lifetime and stores configuration values in static fields. - /// - /// A task representing the asynchronous initialization operation. - private static void Initialize() - { - try - { - // Create a temporary configuration instance for initialization - var configBuilder = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddJsonFile("local.settings.json", optional: true); - var config = configBuilder.Build(); - - // Create a temporary logger for initialization - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - logger.LogInformation("[Initialize] Starting one-time initialization..."); - - // Read and store configuration values in static fields with fallback defaults - _connectionString = config["SERVICE_BUS_CONNECTION_STRING"]; - _clientId = config["AZURE_CLIENT_ID"]; - _fullyQualifiedNamespace = config["SERVICE_BUS_CONNECTION_STRING:fullyQualifiedNamespace"]; - _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; - _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; - _names = config["NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - _hasConnectionString = !string.IsNullOrWhiteSpace(_connectionString); - _hasClientId = !string.IsNullOrWhiteSpace(_clientId); - _hasFullyQualifiedNamespace = !string.IsNullOrWhiteSpace(_fullyQualifiedNamespace); - - // Check if names ae configured. If not use, use default names - if (_names == null || _names.Length == 0) - { - logger.LogWarning("[Initialize] NAMES configuration is missing or empty. Using default names."); - _names = new[] { "Alice", "Paolo", "Leo", "Mia" }; - } - - // Validate configuration and set the flag - _configurationValid = ValidateConfigurationValues(logger); - } - catch (Exception ex) - { - // Log error but don't throw - let functions continue to work even if initialization fails - Console.WriteLine("[Initialize] Initialization failed: {0}", ex.Message); - _configurationValid = false; - } - } - - /// - /// Validates that all required configuration values are present and not empty. - /// With default values in place, only the connection string is mandatory. - /// - /// Logger for reporting validation errors. - /// True if all configuration values are valid, false otherwise. - private static bool ValidateConfigurationValues(ILogger logger) - { - bool isValid = true; - - // Requirement: Must have (ID AND Namespace) OR (Connection String) - if (!(_hasClientId && _hasFullyQualifiedNamespace) && !_hasConnectionString) - { - logger.LogError("[ValidateConfigurationValues] Incomplete configuration. You must provide BOTH Client ID and Namespace, OR a Connection String."); - isValid = false; - } - - // Additional Safety: If they provided a partial Identity, catch it! - if (_hasClientId != _hasFullyQualifiedNamespace && !_hasConnectionString) - { - logger.LogError("[ValidateConfigurationValues] Partial Identity detected. Both Client ID and Namespace are required."); - isValid = false; - } - - // Log the configuration values being used (helpful for debugging) - if (isValid) - { - logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); - logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); - logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); - logger.LogInformation(" - Names: {names}", string.Join(", ", _names != null ? _names : Array.Empty())); - } - - return isValid; - } - - /// - /// Checks if configuration values have been successfully loaded and validated. - /// This method provides a fast runtime check without re-reading configuration. - /// With default values, this primarily checks if the connection string is available. - /// - /// True if configuration is valid and available, false otherwise. - private static bool IsConfigurationValid() - { - // Valid if we have a connection string OR (client ID + fully qualified namespace) - return _configurationValid && (_hasConnectionString || (_hasClientId && _hasFullyQualifiedNamespace)); - } - - /// - /// Processes a Service Bus message by reading, validating, and responding to the input message. - /// - /// The received Service Bus message containing the request payload as JSON. - /// Actions for managing the Service Bus message lifecycle (e.g., completion). - /// - /// A JSON-formatted response message containing a greeting and the current date, or null if the input is invalid. - /// - [Function("GreetingHandler")] - [ServiceBusOutput("%OUTPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING")] - public async Task GreetingHandlerAsync( - [ServiceBusTrigger("%INPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, - ServiceBusMessageActions messageActions) - { - // Log the incoming message details - _logger.LogInformation("[GreetingHandler] Message ID: {id}", message.MessageId); - _logger.LogInformation("[GreetingHandler] Message Body: {body}", message.Body); - _logger.LogInformation("[GreetingHandler] Message Content-Type: {contentType}", message.ContentType); - - // Read the message body as a byte array - byte[] bodyBytes = message.Body.ToArray(); - - // Check that the bodyBytes is not null or empty - if (bodyBytes == null || bodyBytes.Length == 0) - { - _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or null.", message.MessageId); - return null; - } - // Convert the byte array to a string - string json = System.Text.Encoding.UTF8.GetString(bodyBytes); - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or invalid.", message.MessageId); - return null; - } - - // Deserialize the JSON into a RequestMessage object - RequestMessage? requestMessage = JsonSerializer.Deserialize(json); - - // Check that the request message is not null or empty - if (requestMessage == null || string.IsNullOrWhiteSpace(requestMessage?.Name)) - { - _logger.LogError("[GreetingHandler] Received request message [{messageId}] body is empty or invalid.", message.MessageId); - return null; - } - - _logger.LogInformation("[GreetingHandler] Processing request for name: {name}", requestMessage.Name); - - // Record received name in history - lock (_historyLock) - { - _handlerReceived.Add(requestMessage.Name); - } - - // Create the response message - var greetingText = GetGreeting(requestMessage.Name); - var outputObj = new ResponseMessage - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - Text = greetingText - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Complete the message after processing - await messageActions.CompleteMessageAsync(message); - - // Log the successful processing of the message - _logger.LogInformation("[GreetingHandler] Processed message [{messageId}] successfully: {greetingText}", message.MessageId, greetingText); - - // Return the response message - return outputMessage; - } - - /// - /// Timer-triggered function that sends a greeting request message to the input queue. - /// - /// Timer metadata containing schedule status and next occurrence information. - [Function("GreetingRequester")] - [FixedDelayRetry(5, "00:00:10")] - public async Task GreetingRequesterAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) - { - // Log the start of the function execution - _logger.LogInformation("[GreetingRequester] Timer trigger function started."); - - // Ensure one-time initialization has run - _ = _initialized.Value; - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[GreetingRequester] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - if (_names == null || _names.Length == 0) - { - _logger.LogError("[GreetingRequester] Names are not configured. Aborting function execution."); - return; - } - - try - { - // Create Service Bus client - _logger.LogInformation("[GreetingRequester] Creating Service Bus client for sending messages..."); - await using var client = _hasClientId && _hasFullyQualifiedNamespace - ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) - : new ServiceBusClient(_connectionString); - - // Create message sender for the input queue - _logger.LogInformation("[GreetingRequester] Creating sender for input queue '{inputQueue}'", _inputQueueName); - await using var sender = client.CreateSender(_inputQueueName); - - // Create request message with randomly selected name - var random = new Random(); - var selectedName = _names[random.Next(_names.Length)]; - var requestMessage = new RequestMessage { Name = selectedName }; - var messageBody = JsonSerializer.Serialize(requestMessage); - - // Create and send Service Bus message - var serviceBusMessage = new ServiceBusMessage(messageBody) - { - ContentType = "application/json" - }; - - _logger.LogInformation("[GreetingRequester] Sending message to input queue '{inputQueue}'...", _inputQueueName); - await sender.SendMessageAsync(serviceBusMessage); - _logger.LogInformation("[GreetingRequester] Successfully sent message to input queue '{inputQueue}' with name: {Name}", _inputQueueName, selectedName); - - // Record sent name in history - lock (_historyLock) - { - _requesterSent.Add(selectedName); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[GreetingRequester] Failed to send message to input queue '{inputQueue}'", _inputQueueName); - return; - } - - // Log the next scheduled timer occurrence - _logger.LogInformation("[GreetingRequester] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Timer-triggered function that receives and processes greeting response messages from the output queue. - /// - /// Timer metadata containing schedule status and next occurrence information. - [Function("GreetingConsumer")] - [FixedDelayRetry(5, "00:00:10")] - public async Task GreetingConsumerAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) - { - // Log the start of the function execution - _logger.LogInformation("[GreetingConsumer] Timer trigger function started."); - - // Ensure one-time initialization has run - _ = _initialized.Value; - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[GreetingConsumer] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - try - { - // Create Service Bus client for receiving messages from the output queue - _logger.LogInformation("[GreetingConsumer] Creating Service Bus client for receiving messages..."); - await using var client = _hasClientId && _hasFullyQualifiedNamespace - ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) - : new ServiceBusClient(_connectionString); - var receiver = client.CreateReceiver(_outputQueueName); - - _logger.LogInformation("[GreetingConsumer] Starting to receive messages from output queue '{outputQueue}'", _outputQueueName); - - // Loop to receive messages (with timeout to prevent infinite waiting) - var timeout = TimeSpan.FromSeconds(30); - var startTime = DateTime.UtcNow; - - try - { - while (DateTime.UtcNow - startTime < timeout) - { - try - { - // Receive message with a short timeout - var receivedMessage = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)); - - if (receivedMessage == null) - { - _logger.LogInformation("[GreetingConsumer] No more messages available in output queue '{outputQueue}'", _outputQueueName); - break; - } - - // Convert message body to string - var messageBody = receivedMessage.Body.ToString(); - - try - { - // Attempt to deserialize to ResponseMessage - var responseMessage = JsonSerializer.Deserialize(messageBody); - - if (responseMessage != null) - { - _logger.LogInformation("[GreetingConsumer] Successfully received and deserialized message from output queue. Date: {Date}, Text: {Text}", - responseMessage.Date, responseMessage.Text); - - // Complete the message after successful processing - await receiver.CompleteMessageAsync(receivedMessage); - - // Record received greeting in history - lock (_historyLock) - { - _consumerReceived.Add(responseMessage.Text); - } - } - else - { - _logger.LogWarning("[GreetingConsumer] Received message could not be deserialized to ResponseMessage (null result)"); - await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", "Message deserialized to null"); - } - } - catch (JsonException jsonEx) - { - _logger.LogError(jsonEx, "[GreetingConsumer] Failed to deserialize message from output queue. Message body: {messageBody}", messageBody); - await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", jsonEx.Message); - } - } - catch (Exception messageEx) - { - _logger.LogError(messageEx, "[GreetingConsumer] Error occurred while receiving message from output queue '{outputQueue}'", _outputQueueName); - // Continue the loop to try receiving more messages - } - } - } - finally - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - try - { - await receiver.CloseAsync(cts.Token); - } - catch - { /* timeout or error on close */ } - try - { - await client.DisposeAsync(); - } - catch - { /* benign */ - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "[GreetingConsumer] Failed to receive messages from output queue '{outputQueue}'", _outputQueueName); - } - - // Log the next scheduled timer occurrence - _logger.LogInformation("[GreetingConsumer] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Selects a random greeting template and formats it with the given name. - /// The generated greeting is also stored in a circular buffer for later retrieval. - /// - /// The name to include in the greeting. - /// A randomly chosen greeting string addressed to the specified name. - private static string GetGreeting(string name) - { - var template = _greetingTemplates[_random.Next(_greetingTemplates.Length)]; - var greeting = string.Format(template, name); - - lock (_historyLock) - { - _handlerSent.Add(greeting); - } - - return greeting; - } - - /// - /// HTTP-triggered function that returns the most recent greetings from the circular buffer. - /// Greetings are returned in reverse chronological order (newest first). - /// - /// The incoming HTTP request. - /// The number of greetings to return (default: 20, max: 100). - /// An HTTP response containing a JSON array of recent greetings. - [Function("GetGreetings")] - public async Task GetGreetingsAsync( - [HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequestData request, - int count = 20) - { - _logger.LogInformation("[GetGreetings] Retrieving last {count} entries.", count); - - // Clamp count to valid range - if (count < 1) count = 1; - if (count > MaxHistory) count = MaxHistory; - - object history; - lock (_historyLock) - { - history = new - { - requester = new - { - sent = _requesterSent.ToArray(count) - }, - handler = new - { - received = _handlerReceived.ToArray(count), - sent = _handlerSent.ToArray(count) - }, - consumer = new - { - received = _consumerReceived.ToArray(count) - } - }; - } - - var response = request.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/json"); - await response.WriteStringAsync(JsonSerializer.Serialize(history)); - return response; - } - - private sealed class CircularBuffer - { - private readonly string[] _items; - private int _index; - private int _count; - - public CircularBuffer(int capacity) => _items = new string[capacity]; - - public void Add(string item) - { - _items[_index] = item; - _index = (_index + 1) % _items.Length; - if (_count < _items.Length) _count++; - } - - public string[] ToArray(int count) - { - var available = Math.Min(count, _count); - var result = new string[available]; - for (int i = 0; i < available; i++) - { - var idx = (_index - available + i + _items.Length) % _items.Length; - result[i] = _items[idx]; - } - return result; - } - } -} - -/// -/// Represents the input payload for greeting requests. -/// -public class RequestMessage -{ - /// - /// Gets or sets the name to greet. - /// - [JsonPropertyName("name")] - public required string Name { get; set; } -} - -/// -/// Represents the response payload for greeting requests. -/// -public class ResponseMessage -{ - /// - /// Gets or sets the date of the response message. - /// - [JsonPropertyName("date")] - public required string Date { get; set; } - - /// - /// Gets or sets the text of the response message. - /// - [JsonPropertyName("text")] - public required string Text { get; set; } +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Azure.Identity; +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace LocalStack.Azure.Samples; + +/// +/// A simple Azure Function that processes Service Bus messages and responds with a greeting. +/// +public class HelloWorld +{ + // Instance field for logging - keeps proper Azure Functions execution context + private readonly ILogger _logger; + + // Static configuration values - initialized once per application lifetime + private static string? _connectionString; + private static string? _clientId; + private static string? _fullyQualifiedNamespace; + private static bool _hasConnectionString; + private static bool _hasClientId; + private static bool _hasFullyQualifiedNamespace; + private static string? _inputQueueName; + private static string? _outputQueueName; + private static bool _configurationValid = false; + private static string[]? _names; + + // Greeting templates used by GetGreeting to produce varied responses + private static readonly string[] _greetingTemplates = new[] + { + "Hello {0}, how are you?", + "Hi {0}, great to see you!", + "Hey {0}, hope you're having a wonderful day!", + "Good day {0}, welcome aboard!", + "Greetings {0}, nice to meet you!", + "Howdy {0}, what's going on?", + "Welcome {0}, glad you're here!", + "Salutations {0}, how's everything going?" + }; + + private static readonly Random _random = new(); + + // Circular buffers for message history across all functions + private const int MaxHistory = 100; + private static readonly object _historyLock = new(); + private static readonly CircularBuffer _requesterSent = new(MaxHistory); + private static readonly CircularBuffer _handlerReceived = new(MaxHistory); + private static readonly CircularBuffer _handlerSent = new(MaxHistory); + private static readonly CircularBuffer _consumerReceived = new(MaxHistory); + + // Static initialization - runs once per application lifetime + private static readonly Lazy _initialized = new Lazy(() => { Initialize(); return true; }); + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory used to create loggers for this class. + public HelloWorld(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). + /// This method runs exactly once per application lifetime and stores configuration values in static fields. + /// + /// A task representing the asynchronous initialization operation. + private static void Initialize() + { + try + { + // Create a temporary configuration instance for initialization + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("local.settings.json", optional: true); + var config = configBuilder.Build(); + + // Create a temporary logger for initialization + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + + logger.LogInformation("[Initialize] Starting one-time initialization..."); + + // Read and store configuration values in static fields with fallback defaults + _connectionString = config["SERVICE_BUS_CONNECTION_STRING"]; + _clientId = config["AZURE_CLIENT_ID"]; + _fullyQualifiedNamespace = config["SERVICE_BUS_CONNECTION_STRING:fullyQualifiedNamespace"]; + _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; + _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; + _names = config["NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + _hasConnectionString = !string.IsNullOrWhiteSpace(_connectionString); + _hasClientId = !string.IsNullOrWhiteSpace(_clientId); + _hasFullyQualifiedNamespace = !string.IsNullOrWhiteSpace(_fullyQualifiedNamespace); + + // Check if names ae configured. If not use, use default names + if (_names == null || _names.Length == 0) + { + logger.LogWarning("[Initialize] NAMES configuration is missing or empty. Using default names."); + _names = new[] { "Alice", "Paolo", "Leo", "Mia" }; + } + + // Validate configuration and set the flag + _configurationValid = ValidateConfigurationValues(logger); + } + catch (Exception ex) + { + // Log error but don't throw - let functions continue to work even if initialization fails + Console.WriteLine("[Initialize] Initialization failed: {0}", ex.Message); + _configurationValid = false; + } + } + + /// + /// Validates that all required configuration values are present and not empty. + /// With default values in place, only the connection string is mandatory. + /// + /// Logger for reporting validation errors. + /// True if all configuration values are valid, false otherwise. + private static bool ValidateConfigurationValues(ILogger logger) + { + bool isValid = true; + + // Requirement: Must have (ID AND Namespace) OR (Connection String) + if (!(_hasClientId && _hasFullyQualifiedNamespace) && !_hasConnectionString) + { + logger.LogError("[ValidateConfigurationValues] Incomplete configuration. You must provide BOTH Client ID and Namespace, OR a Connection String."); + isValid = false; + } + + // Additional Safety: If they provided a partial Identity, catch it! + if (_hasClientId != _hasFullyQualifiedNamespace && !_hasConnectionString) + { + logger.LogError("[ValidateConfigurationValues] Partial Identity detected. Both Client ID and Namespace are required."); + isValid = false; + } + + // Log the configuration values being used (helpful for debugging) + if (isValid) + { + logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); + logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); + logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); + logger.LogInformation(" - Names: {names}", string.Join(", ", _names != null ? _names : Array.Empty())); + } + + return isValid; + } + + /// + /// Checks if configuration values have been successfully loaded and validated. + /// This method provides a fast runtime check without re-reading configuration. + /// With default values, this primarily checks if the connection string is available. + /// + /// True if configuration is valid and available, false otherwise. + private static bool IsConfigurationValid() + { + // Valid if we have a connection string OR (client ID + fully qualified namespace) + return _configurationValid && (_hasConnectionString || (_hasClientId && _hasFullyQualifiedNamespace)); + } + + /// + /// Processes a Service Bus message by reading, validating, and responding to the input message. + /// + /// The received Service Bus message containing the request payload as JSON. + /// Actions for managing the Service Bus message lifecycle (e.g., completion). + /// + /// A JSON-formatted response message containing a greeting and the current date, or null if the input is invalid. + /// + [Function("GreetingHandler")] + [ServiceBusOutput("%OUTPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING")] + public async Task GreetingHandlerAsync( + [ServiceBusTrigger("%INPUT_QUEUE_NAME%", Connection = "SERVICE_BUS_CONNECTION_STRING", AutoCompleteMessages = false)] ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions) + { + // Log the incoming message details + _logger.LogInformation("[GreetingHandler] Message ID: {id}", message.MessageId); + _logger.LogInformation("[GreetingHandler] Message Body: {body}", message.Body); + _logger.LogInformation("[GreetingHandler] Message Content-Type: {contentType}", message.ContentType); + + // Read the message body as a byte array + byte[] bodyBytes = message.Body.ToArray(); + + // Check that the bodyBytes is not null or empty + if (bodyBytes == null || bodyBytes.Length == 0) + { + _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or null.", message.MessageId); + return null; + } + // Convert the byte array to a string + string json = System.Text.Encoding.UTF8.GetString(bodyBytes); + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[GreetingHandler] Received message [{messageId}] body is empty or invalid.", message.MessageId); + return null; + } + + // Deserialize the JSON into a RequestMessage object + RequestMessage? requestMessage = JsonSerializer.Deserialize(json); + + // Check that the request message is not null or empty + if (requestMessage == null || string.IsNullOrWhiteSpace(requestMessage?.Name)) + { + _logger.LogError("[GreetingHandler] Received request message [{messageId}] body is empty or invalid.", message.MessageId); + return null; + } + + _logger.LogInformation("[GreetingHandler] Processing request for name: {name}", requestMessage.Name); + + // Record received name in history + lock (_historyLock) + { + _handlerReceived.Add(requestMessage.Name); + } + + // Create the response message + var greetingText = GetGreeting(requestMessage.Name); + var outputObj = new ResponseMessage + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + Text = greetingText + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Complete the message after processing + await messageActions.CompleteMessageAsync(message); + + // Log the successful processing of the message + _logger.LogInformation("[GreetingHandler] Processed message [{messageId}] successfully: {greetingText}", message.MessageId, greetingText); + + // Return the response message + return outputMessage; + } + + /// + /// Timer-triggered function that sends a greeting request message to the input queue. + /// + /// Timer metadata containing schedule status and next occurrence information. + [Function("GreetingRequester")] + [FixedDelayRetry(5, "00:00:10")] + public async Task GreetingRequesterAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) + { + // Log the start of the function execution + _logger.LogInformation("[GreetingRequester] Timer trigger function started."); + + // Ensure one-time initialization has run + _ = _initialized.Value; + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[GreetingRequester] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + if (_names == null || _names.Length == 0) + { + _logger.LogError("[GreetingRequester] Names are not configured. Aborting function execution."); + return; + } + + try + { + // Create Service Bus client + _logger.LogInformation("[GreetingRequester] Creating Service Bus client for sending messages..."); + await using var client = _hasClientId && _hasFullyQualifiedNamespace + ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) + : new ServiceBusClient(_connectionString); + + // Create message sender for the input queue + _logger.LogInformation("[GreetingRequester] Creating sender for input queue '{inputQueue}'", _inputQueueName); + await using var sender = client.CreateSender(_inputQueueName); + + // Create request message with randomly selected name + var random = new Random(); + var selectedName = _names[random.Next(_names.Length)]; + var requestMessage = new RequestMessage { Name = selectedName }; + var messageBody = JsonSerializer.Serialize(requestMessage); + + // Create and send Service Bus message + var serviceBusMessage = new ServiceBusMessage(messageBody) + { + ContentType = "application/json" + }; + + _logger.LogInformation("[GreetingRequester] Sending message to input queue '{inputQueue}'...", _inputQueueName); + await sender.SendMessageAsync(serviceBusMessage); + _logger.LogInformation("[GreetingRequester] Successfully sent message to input queue '{inputQueue}' with name: {Name}", _inputQueueName, selectedName); + + // Record sent name in history + lock (_historyLock) + { + _requesterSent.Add(selectedName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[GreetingRequester] Failed to send message to input queue '{inputQueue}'", _inputQueueName); + return; + } + + // Log the next scheduled timer occurrence + _logger.LogInformation("[GreetingRequester] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Timer-triggered function that receives and processes greeting response messages from the output queue. + /// + /// Timer metadata containing schedule status and next occurrence information. + [Function("GreetingConsumer")] + [FixedDelayRetry(5, "00:00:10")] + public async Task GreetingConsumerAsync([TimerTrigger("%TIMER_SCHEDULE%", RunOnStartup = true)] TimerInfo timerInfo) + { + // Log the start of the function execution + _logger.LogInformation("[GreetingConsumer] Timer trigger function started."); + + // Ensure one-time initialization has run + _ = _initialized.Value; + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[GreetingConsumer] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + try + { + // Create Service Bus client for receiving messages from the output queue + _logger.LogInformation("[GreetingConsumer] Creating Service Bus client for receiving messages..."); + await using var client = _hasClientId && _hasFullyQualifiedNamespace + ? new ServiceBusClient(_fullyQualifiedNamespace, new DefaultAzureCredential()) + : new ServiceBusClient(_connectionString); + var receiver = client.CreateReceiver(_outputQueueName); + + _logger.LogInformation("[GreetingConsumer] Starting to receive messages from output queue '{outputQueue}'", _outputQueueName); + + // Loop to receive messages (with timeout to prevent infinite waiting) + var timeout = TimeSpan.FromSeconds(30); + var startTime = DateTime.UtcNow; + + try + { + while (DateTime.UtcNow - startTime < timeout) + { + try + { + // Receive message with a short timeout + var receivedMessage = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5)); + + if (receivedMessage == null) + { + _logger.LogInformation("[GreetingConsumer] No more messages available in output queue '{outputQueue}'", _outputQueueName); + break; + } + + // Convert message body to string + var messageBody = receivedMessage.Body.ToString(); + + try + { + // Attempt to deserialize to ResponseMessage + var responseMessage = JsonSerializer.Deserialize(messageBody); + + if (responseMessage != null) + { + _logger.LogInformation("[GreetingConsumer] Successfully received and deserialized message from output queue. Date: {Date}, Text: {Text}", + responseMessage.Date, responseMessage.Text); + + // Complete the message after successful processing + await receiver.CompleteMessageAsync(receivedMessage); + + // Record received greeting in history + lock (_historyLock) + { + _consumerReceived.Add(responseMessage.Text); + } + } + else + { + _logger.LogWarning("[GreetingConsumer] Received message could not be deserialized to ResponseMessage (null result)"); + await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", "Message deserialized to null"); + } + } + catch (JsonException jsonEx) + { + _logger.LogError(jsonEx, "[GreetingConsumer] Failed to deserialize message from output queue. Message body: {messageBody}", messageBody); + await receiver.DeadLetterMessageAsync(receivedMessage, "DeserializationFailed", jsonEx.Message); + } + } + catch (Exception messageEx) + { + _logger.LogError(messageEx, "[GreetingConsumer] Error occurred while receiving message from output queue '{outputQueue}'", _outputQueueName); + // Continue the loop to try receiving more messages + } + } + } + finally + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + await receiver.CloseAsync(cts.Token); + } + catch + { /* timeout or error on close */ } + try + { + await client.DisposeAsync(); + } + catch + { /* benign */ + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[GreetingConsumer] Failed to receive messages from output queue '{outputQueue}'", _outputQueueName); + } + + // Log the next scheduled timer occurrence + _logger.LogInformation("[GreetingConsumer] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Selects a random greeting template and formats it with the given name. + /// The generated greeting is also stored in a circular buffer for later retrieval. + /// + /// The name to include in the greeting. + /// A randomly chosen greeting string addressed to the specified name. + private static string GetGreeting(string name) + { + var template = _greetingTemplates[_random.Next(_greetingTemplates.Length)]; + var greeting = string.Format(template, name); + + lock (_historyLock) + { + _handlerSent.Add(greeting); + } + + return greeting; + } + + /// + /// HTTP-triggered function that returns the most recent greetings from the circular buffer. + /// Greetings are returned in reverse chronological order (newest first). + /// + /// The incoming HTTP request. + /// The number of greetings to return (default: 20, max: 100). + /// An HTTP response containing a JSON array of recent greetings. + [Function("GetGreetings")] + public async Task GetGreetingsAsync( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequestData request, + int count = 20) + { + _logger.LogInformation("[GetGreetings] Retrieving last {count} entries.", count); + + // Clamp count to valid range + if (count < 1) count = 1; + if (count > MaxHistory) count = MaxHistory; + + object history; + lock (_historyLock) + { + history = new + { + requester = new + { + sent = _requesterSent.ToArray(count) + }, + handler = new + { + received = _handlerReceived.ToArray(count), + sent = _handlerSent.ToArray(count) + }, + consumer = new + { + received = _consumerReceived.ToArray(count) + } + }; + } + + var response = request.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/json"); + await response.WriteStringAsync(JsonSerializer.Serialize(history)); + return response; + } + + private sealed class CircularBuffer + { + private readonly string[] _items; + private int _index; + private int _count; + + public CircularBuffer(int capacity) => _items = new string[capacity]; + + public void Add(string item) + { + _items[_index] = item; + _index = (_index + 1) % _items.Length; + if (_count < _items.Length) _count++; + } + + public string[] ToArray(int count) + { + var available = Math.Min(count, _count); + var result = new string[available]; + for (int i = 0; i < available; i++) + { + var idx = (_index - available + i + _items.Length) % _items.Length; + result[i] = _items[idx]; + } + return result; + } + } +} + +/// +/// Represents the input payload for greeting requests. +/// +public class RequestMessage +{ + /// + /// Gets or sets the name to greet. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/// +/// Represents the response payload for greeting requests. +/// +public class ResponseMessage +{ + /// + /// Gets or sets the date of the response message. + /// + [JsonPropertyName("date")] + public required string Date { get; set; } + + /// + /// Gets or sets the text of the response message. + /// + [JsonPropertyName("text")] + public required string Text { get; set; } } \ No newline at end of file diff --git a/samples/function-app-service-bus/dotnet/src/Program.cs b/samples/function-app-service-bus/dotnet/src/Program.cs index c76837e..51336f3 100644 --- a/samples/function-app-service-bus/dotnet/src/Program.cs +++ b/samples/function-app-service-bus/dotnet/src/Program.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Hosting; - -var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .Build(); - -host.Run(); +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-service-bus/dotnet/src/sample.csproj b/samples/function-app-service-bus/dotnet/src/sample.csproj index 8b21d36..c691742 100644 --- a/samples/function-app-service-bus/dotnet/src/sample.csproj +++ b/samples/function-app-service-bus/dotnet/src/sample.csproj @@ -1,31 +1,31 @@ - - - net10.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + \ No newline at end of file diff --git a/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs b/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs index ca1d8eb..9c500bb 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs +++ b/samples/function-app-storage-http/dotnet/src/sample/GameSessionManager.cs @@ -1,778 +1,778 @@ -using System.Net; -using System.Text.Json; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; -using Azure.Storage.Blobs; -using Azure.Storage.Queues; -using Azure.Storage.Queues.Models; -using Azure.Data.Tables; -using Azure; - -namespace LocalStack.Azure.Samples; - -/// -/// Manages game sessions and player interactions using Azure Functions with hybrid storage approach. -/// This class demonstrates a complete gaming scoreboard system with blob storage for game files, -/// queue processing for game events, table storage for winners, and internal dictionary for active game data. -/// The system processes GameStatusRequest/GameStatusResponse messages and maintains game state in memory -/// for improved performance while still utilizing Azure Storage services for persistence and messaging. -/// -public class GameSessionManager -{ - // Instance field for logging - keeps proper Azure Functions execution context - private readonly ILogger _logger; - - // Static configuration values - initialized once per application lifetime - private static string? _connectionString; - private static string? _inputQueueName; - private static string? _outputQueueName; - private static string? _triggerQueueName; - private static string? _inputContainerName; - private static string? _outputContainerName; - private static string? _inputTableName; - private static string? _outputTableName; - private static string[]? _playerNames; - private static bool _configurationValid; - private static int _gameId = 1; - - // Static dictionary to store game data in memory - game ID as key, list of player scores as value - // This replaces Azure Table Storage for demonstration purposes and provides faster access - private static readonly Dictionary> _gameData = new Dictionary>(); - private static readonly object _gameDataLock = new object(); - - // Static initialization - runs once per application lifetime - private static readonly Lazy _infrastructureInitialization = new Lazy(() => InitializeInfrastructureOnceAsync()); - - /// - /// Initializes a new instance of the class. - /// - /// The logger factory used to create loggers for this class. - public GameSessionManager(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - /// - /// Ensures that Azure infrastructure (queues, containers, tables) is initialized exactly once - /// during the application lifetime. This method is thread-safe and idempotent. - /// - /// A task that completes when infrastructure initialization is finished. - private async Task EnsureInfrastructureInitializedAsync() - { - await _infrastructureInitialization.Value; - } - - /// - /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). - /// This method runs exactly once per application lifetime and stores configuration values in static fields. - /// - /// A task representing the asynchronous initialization operation. - private static async Task InitializeInfrastructureOnceAsync() - { - try - { - // Create a temporary configuration instance for initialization - var configBuilder = new ConfigurationBuilder() - .AddEnvironmentVariables() - .AddJsonFile("local.settings.json", optional: true); - var config = configBuilder.Build(); - - // Create a temporary logger for initialization - using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - logger.LogInformation("[InitializeInfrastructureOnceAsync] Starting one-time infrastructure initialization..."); - - // Read and store configuration values in static fields with fallback defaults - _connectionString = config["STORAGE_ACCOUNT_CONNECTION_STRING"]; - _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; - _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; - _triggerQueueName = config["TRIGGER_QUEUE_NAME"] ?? "trigger"; - _inputContainerName = config["INPUT_STORAGE_CONTAINER_NAME"] ?? "input"; - _outputContainerName = config["OUTPUT_STORAGE_CONTAINER_NAME"] ?? "output"; - _inputTableName = config["INPUT_TABLE_NAME"] ?? "scoreboards"; - _outputTableName = config["OUTPUT_TABLE_NAME"] ?? "winners"; - _playerNames = config["PLAYER_NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - // Check if player names ae configured. If not use, use default names - if (_playerNames == null || _playerNames.Length == 0) - { - logger.LogWarning("[InitializeInfrastructureOnceAsync] PLAYER_NAMES configuration is missing or empty. Using default names."); - _playerNames = new[] { "Alice", "Anastasia", "Paolo", "Leo", "Mia" }; - } - - // Validate configuration and set the flag - _configurationValid = ValidateConfigurationValues(logger); - - if (_configurationValid && _connectionString != null) - { - // Initialize all infrastructure components - await InitializeQueuesAsync(_connectionString, logger, new[] { _inputQueueName, _outputQueueName, _triggerQueueName }.Where(q => q != null).ToArray()!); - await InitializeContainersAsync(_connectionString, logger, new[] { _inputContainerName, _outputContainerName }.Where(c => c != null).ToArray()!); - await InitializeTablesAsync(_connectionString, logger, new[] { _inputTableName, _outputTableName }.Where(t => t != null).ToArray()!); - - logger.LogInformation("[InitializeInfrastructureOnceAsync] Infrastructure initialization completed successfully."); - } - else - { - logger.LogError("[InitializeInfrastructureOnceAsync] Configuration validation failed. Infrastructure initialization aborted."); - } - } - catch (Exception ex) - { - // Log error but don't throw - let functions continue to work even if initialization fails - Console.WriteLine("[InitializeInfrastructureOnceAsync] Failed to initialize infrastructure: {0}", ex.Message); - _configurationValid = false; - } - } - - /// - /// Validates that all required configuration values are present and not empty. - /// With default values in place, only the connection string is mandatory. - /// - /// Logger for reporting validation errors. - /// True if all configuration values are valid, false otherwise. - private static bool ValidateConfigurationValues(ILogger logger) - { - bool isValid = true; - - // Connection string is the only truly required value - everything else has defaults - if (string.IsNullOrWhiteSpace(_connectionString)) - { - logger.LogError("[ValidateConfigurationValues] STORAGE_ACCOUNT_CONNECTION_STRING configuration value is missing and is required."); - isValid = false; - } - - // Log the configuration values being used (helpful for debugging) - if (isValid) - { - logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); - logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); - logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); - logger.LogInformation(" - Trigger Queue: {triggerQueue}", _triggerQueueName); - logger.LogInformation(" - Input Container: {inputContainer}", _inputContainerName); - logger.LogInformation(" - Output Container: {outputContainer}", _outputContainerName); - logger.LogInformation(" - Input Table: {inputTable}", _inputTableName); - logger.LogInformation(" - Output Table: {outputTable}", _outputTableName); - } - - return isValid; - } - - /// - /// Checks if configuration values have been successfully loaded and validated. - /// This method provides a fast runtime check without re-reading configuration. - /// With default values, this primarily checks if the connection string is available. - /// - /// True if configuration is valid and available, false otherwise. - private static bool IsConfigurationValid() - { - // Since we have defaults for all values except connection string, - // we only need to check the configuration validation flag and connection string - return _configurationValid && !string.IsNullOrWhiteSpace(_connectionString); - } - - /// - /// Static version of queue initialization for one-time setup. - /// - private static async Task InitializeQueuesAsync(string connectionString, ILogger logger, string[] queues) - { - try - { - foreach (var queueName in queues.Where(q => !string.IsNullOrWhiteSpace(q))) - { - var queueClient = new QueueClient(connectionString, queueName); - await queueClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeQueuesAsync] Initialized queue: {queueName}", queueName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeQueuesAsync] Failed to initialize queues."); - } - } - - /// - /// Static version of container initialization for one-time setup. - /// - private static async Task InitializeContainersAsync(string connectionString, ILogger logger, string[] containers) - { - try - { - var blobServiceClient = new BlobServiceClient(connectionString); - foreach (var containerName in containers.Where(c => !string.IsNullOrWhiteSpace(c))) - { - var containerClient = blobServiceClient.GetBlobContainerClient(containerName); - await containerClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeContainersAsync] Initialized container: {containerName}", containerName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeContainersAsync] Failed to initialize containers."); - } - } - - /// - /// Static version of table initialization for one-time setup. - /// - private static async Task InitializeTablesAsync(string connectionString, ILogger logger, string[] tables) - { - try - { - foreach (var tableName in tables.Where(t => !string.IsNullOrWhiteSpace(t))) - { - var tableClient = new TableClient(connectionString, tableName); - await tableClient.CreateIfNotExistsAsync(); - logger.LogInformation("[InitializeTablesAsync] Initialized table: {tableName}", tableName); - } - } - catch (Exception ex) - { - logger.LogError(ex, "[InitializeTablesAsync] Failed to initialize tables."); - } - } - - /// - /// Handles HTTP GET requests to retrieve player score for a specific game and player. - /// - /// The HTTP request data. - /// The game ID to retrieve scores for, provided in the route. - /// The player name to retrieve status for, provided in the route. - /// An HTTP response with player score information or an error message. - [Function("GetPlayerScore")] - public async Task GetPlayerScoreAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "player/{gameId}/{name}/status")] HttpRequestData request, int gameId, string name) - { - HttpResponseData response; - - // Log the incoming request - _logger.LogInformation("[GetPlayerScore] Received GET request with gameId = {gameId}, name = {name}.", gameId, name ?? "NULL"); - - // Validate the name parameter - if (name == null || string.IsNullOrWhiteSpace(name)) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("Invalid parameters: name parameter is required."); - return response; - } - - // Check if the game and player exist in the internal dictionary - PlayerScore? playerScore = null; - lock (_gameDataLock) - { - if (_gameData.TryGetValue(gameId, out var players)) - { - playerScore = players.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - } - - if (playerScore == null) - { - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {gameId} and player '{name}' tuple not found."); - return response; - } - - // Return the player score information - response = request.CreateResponse(HttpStatusCode.OK); - - // Create the response message - var outputObj = new PlayerScoreResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = gameId, - Name = playerScore.Name, - Score = playerScore.Score - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Write the response message to the HTTP response - await response.WriteStringAsync(outputMessage); - - // Log the successful processing - _logger.LogInformation("[GetPlayerScore] Processed request successfully with gameId = {gameId}, name = {name}, score = {score}.", gameId, name, playerScore.Score); - - // Return the HTTP response - return response; - } - - /// - /// Handles HTTP POST and PUT requests to retrieve game status information. - /// Accepts a GameStatusRequest in the request body and returns comprehensive game status - /// including winner determination and all player scores for the specified game. - /// - /// The HTTP request data containing a JSON-serialized GameStatusRequest. - /// An HTTP response with GameStatusResponse containing game status details, winner, and all players, or an error message if the game is not found or invalid. - [Function("CreateGameStatus")] - public async Task CreateGameStatusAsync([HttpTrigger(AuthorizationLevel.Function, "post", "put", Route = "game/session")] HttpRequestData request) - { - HttpResponseData response; - - // Log the incoming request method - _logger.LogInformation("[CreateGameStatus] Received {method} request.", request.Method); - - // Read the request body as a string - var requestBody = await request.ReadAsStringAsync(); - - // Validate that the request body is not empty - if (requestBody == null || string.IsNullOrWhiteSpace(requestBody)) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is required."); - return response; - } - - GameStatusRequest? requestMessage; - try - { - // Attempt to deserialize the request body into a GameStatusRequest object - requestMessage = JsonSerializer.Deserialize(requestBody); - } - catch (JsonException) - { - // Handle invalid JSON format - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is not in the proper format."); - return response; - } - - // Validate that the GameId property is present and valid - if (requestMessage == null || requestMessage.GameId <= 0) - { - response = request.CreateResponse(HttpStatusCode.BadRequest); - await response.WriteStringAsync("[CreateGameStatus] Invalid request message: 'GameId' is required and must be greater than 0."); - return response; - } - - // Check if the game exists in the internal dictionary - List? players = null; - bool gameFound = false; - lock (_gameDataLock) - { - gameFound = _gameData.TryGetValue(requestMessage.GameId, out players); - } - - if (!gameFound || players == null) - { - _logger.LogWarning("[CreateGameStatus] Game {gameId} not found in internal data store.", requestMessage.GameId); - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {requestMessage.GameId} not found."); - return response; - } - - if (players.Count == 0) - { - _logger.LogWarning("[CreateGameStatus] Game {gameId} has no player data.", requestMessage.GameId); - response = request.CreateResponse(HttpStatusCode.NotFound); - await response.WriteStringAsync($"Game {requestMessage.GameId} has no player data."); - return response; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Return a response if the request message is valid - response = request.CreateResponse(HttpStatusCode.OK); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = requestMessage.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Write the response message to the HTTP response - await response.WriteStringAsync(outputMessage); - - // Log the successful processing - _logger.LogInformation("[CreateGameStatus] Processed request successfully with gameId = {gameId}, winner = {winner}.", requestMessage.GameId, winner.Name); - - // Return the HTTP response - return response; - } - - /// - /// Processes uploaded game status files from blob storage and generates comprehensive game status responses. - /// Deserializes GameStatusRequest from blob content, retrieves game data from internal dictionary, - /// determines the winner, and returns a GameStatusResponse with complete game information. - /// - /// The blob content as byte array containing JSON-serialized GameStatusRequest. - /// The name of the blob file being processed. - /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players, or null if the input is invalid or game not found. - [Function("ProcessGameFile")] - [BlobOutput("%OUTPUT_STORAGE_CONTAINER_NAME%/{name}")] - public string? ProcessGameFile( - [BlobTrigger("%INPUT_STORAGE_CONTAINER_NAME%/{name}", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] byte[] blobBytes, - string name) - { - // Check that the blobBytes is not null or empty - if (blobBytes == null || blobBytes.Length == 0) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or null.", name); - return null; - } - - // Convert the byte array to a string - string json = System.Text.Encoding.UTF8.GetString(blobBytes); - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or invalid.", name); - return null; - } - - // Deserialize the JSON into a GameStatusRequest object - GameStatusRequest? gameStatusRequest = JsonSerializer.Deserialize(json); - - // Check that the request message is not null - if (gameStatusRequest == null) - { - _logger.LogError("[ProcessGameFile] Received [{name}] blob contains invalid GameStatusRequest.", name); - return null; - } - - // Check if the game exists in the internal dictionary - List? players = null; - lock (_gameDataLock) - { - if (!_gameData.TryGetValue(gameStatusRequest.GameId, out players)) - { - _logger.LogWarning("[ProcessGameFile] Game {gameId} not found in internal data store.", gameStatusRequest.GameId); - return null; - } - } - - if (players == null || players.Count == 0) - { - _logger.LogWarning("[ProcessGameFile] Game {gameId} has no player data.", gameStatusRequest.GameId); - return null; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = gameStatusRequest.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Log the successful processing of the blob - _logger.LogInformation("[ProcessGameFile] Processed blob [{name}] successfully for game {gameId}.", name, gameStatusRequest.GameId); - - // Return the response message - return outputMessage; - } - - /// - /// Handles game events from Azure Storage Queue and processes game status requests. - /// Deserializes GameStatusRequest from queue messages, retrieves game data from internal dictionary, - /// determines the winner, and returns a GameStatusResponse for further processing in the output queue. - /// - /// The incoming queue message containing JSON-serialized GameStatusRequest data. - /// The function execution context provided by the Azure Functions runtime. - /// - /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players for output queue processing, or null if the input is invalid or game not found. - /// - [Function("HandleGameEvent")] - [QueueOutput("%OUTPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] - public string? HandleGameEvent([QueueTrigger("%INPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] QueueMessage message, FunctionContext context) - { - - // Check that the message and the body are not null or empty - if (message == null || string.IsNullOrWhiteSpace(message.Body?.ToString())) - { - _logger.LogError("[HandleGameEvent] Received queue message is null or empty."); - return null; - } - - var json = message.Body.ToString() ?? string.Empty; - - // Check that the JSON is not null or empty - if (string.IsNullOrEmpty(json)) - { - _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message is empty or invalid.", message.MessageId); - return null; - } - - // Deserialize the JSON into a GameStatusRequest object - GameStatusRequest? requestMessage = JsonSerializer.Deserialize(json); - - // Check that the request message is not null - if (requestMessage == null) - { - _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message contains invalid GameStatusRequest.", message.MessageId); - return null; - } - - // Check if the game exists in the internal dictionary - List? players = null; - lock (_gameDataLock) - { - if (!_gameData.TryGetValue(requestMessage.GameId, out players)) - { - _logger.LogWarning("[HandleGameEvent] Game {gameId} not found in internal data store.", requestMessage.GameId); - // Return null for now, but could create an error response if needed - return null; - } - } - - if (players == null || players.Count == 0) - { - _logger.LogWarning("[HandleGameEvent] Game {gameId} has no player data.", requestMessage.GameId); - return null; - } - - // Find the winner (player with highest score) - var winner = players.OrderByDescending(p => p.Score).First(); - - // Create the response message - var outputObj = new GameStatusResponse - { - Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), - GameId = requestMessage.GameId, - Winner = winner.Name, - Players = new List(players) // Create a copy of the list - }; - var outputMessage = JsonSerializer.Serialize(outputObj); - - // Log the successful processing of the queue message - _logger.LogInformation("[HandleGameEvent] Processed queue message [{messageId}] successfully for game {gameId}.", message.MessageId, requestMessage.GameId); - - // Return the response message - return outputMessage; - - } - - /// - /// Processes game scoreboards to determine winners and manage game results. - /// Retrieves scoreboard entries for a game, finds the highest score, and records the winner. - /// - /// The game ID from the queue message to filter scoreboard entries. - /// The scoreboard entities matching the specified game ID. - /// The winning ScoreboardEntity with the highest score, or null if no entities are found. - [Function("ProcessScoreboard")] - [TableOutput("%OUTPUT_TABLE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] - public ScoreboardEntity? ProcessScoreboard( - [QueueTrigger("%TRIGGER_QUEUE_NAME%")] string gameId, - [TableInput("%INPUT_TABLE_NAME%", "{queueTrigger}", - Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] IEnumerable entities) - { - // Find the entity with the highest score - var winner = entities.OrderByDescending(e => e.Score).FirstOrDefault(); - _logger.LogInformation("[ProcessScoreboard] Processed game ID {gameId}. Winner: {winnerName} with score {score}.", gameId, winner?.PlayerName ?? "No winner", winner?.Score.ToString() ?? "N/A"); - - if (winner != null) - { - // Create a new entity for the output table with a new RowKey - var winnerEntity = new ScoreboardEntity - { - PartitionKey = $"winner-game-{int.Parse(gameId):D3}", - RowKey = Guid.NewGuid().ToString(), - GameId = winner.GameId, - PlayerName = winner.PlayerName, - Score = winner.Score, - Timestamp = DateTimeOffset.UtcNow, - ETag = ETag.All - }; - - return winnerEntity; - } - - return null; - } - - /// - /// Timer-triggered function that generates new game rounds with random player data and initiates the complete gaming workflow. - /// Creates GameStatusRequest objects, uploads them as blobs, sends queue messages, creates internal dictionary entries - /// with random player scores, and triggers the scoreboard processing pipeline. Runs every minute and on startup. - /// - /// Timer metadata containing schedule status and next occurrence information. - /// A task that represents the asynchronous game round generation operation. - [Function("CreateGame")] - [FixedDelayRetry(5, "00:00:10")] - public async Task CreateGameAsync([TimerTrigger("0 */1 * * * *", RunOnStartup = true)] TimerInfo timerInfo) - { - _logger.LogInformation("[CreateGameAsync] Triggered execution."); - - // Ensure infrastructure is initialized (runs only once per app lifetime) - await EnsureInfrastructureInitializedAsync(); - - // Fast configuration validation using pre-loaded static values - if (!IsConfigurationValid()) - { - _logger.LogError("[CreateGameAsync] Configuration is invalid or not loaded. Aborting function execution."); - return; - } - - if (_playerNames == null || _playerNames.Length == 0) - { - _logger.LogError("[CreateGameAsync] Player names are not configured. Aborting function execution."); - return; - } - - var random = new Random(); - var gameStatusRequest = new GameStatusRequest { GameId = _gameId }; - - // Serialize the request message to JSON - var message = JsonSerializer.Serialize(gameStatusRequest); - - // Log the generated message and configuration values - _logger.LogInformation("[CreateGameAsync] Generated message: {message}", message); - - // Create a unique blob name with the required format - var now = DateTime.UtcNow; - var blobFileName = $"game-{_gameId:D3}-status-{now:yyyy-MM-dd-HH-mm-ss}.json"; - - // Create scoreboard entries for all players using the updated method - await CreateScoreboardEntriesAsync(_connectionString, _inputTableName); - - // Upload blob to the input container - await UploadBlobAsync(_connectionString, _inputContainerName, blobFileName, message); - - // Send message to the input queue - await SendQueueMessageAsync(_connectionString, _inputQueueName, message); - - // Send message to the trigger queue - await SendQueueMessageAsync(_connectionString, _triggerQueueName, _gameId.ToString()); - - // Increment game ID for next execution - _gameId++; - - // Log the next scheduled timer occurrence - _logger.LogInformation("[CreateGameAsync] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); - } - - /// - /// Uploads a message as a blob to the specified Azure Storage container. - /// - /// The storage account connection string. - /// The name of the container to upload to. - /// The name of the blob file to create. - /// The message content to upload as blob data. - /// A task that represents the asynchronous upload operation. - private async Task UploadBlobAsync(string? connectionString, string? inputContainerName, string blobFileName, string message) - { - try - { - var blobServiceClient = new BlobServiceClient(connectionString); - var blobContainerClient = blobServiceClient.GetBlobContainerClient(inputContainerName); - - await blobContainerClient.CreateIfNotExistsAsync(); - - var blobClient = blobContainerClient.GetBlobClient(blobFileName); - - using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(message))) - { - await blobClient.UploadAsync(stream, overwrite: true); - } - _logger.LogInformation("[UploadBlobAsync] Uploaded blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); - } - catch (Exception ex) - { - _logger.LogError(ex, "[UploadBlobAsync] Failed to upload blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); - } - } - - /// - /// Sends a message to the specified Azure Storage queue. - /// - /// The storage account connection string. - /// The name of the queue to send the message to. - /// The message content to send (will be Base64 encoded). - /// A task that represents the asynchronous send operation. - private async Task SendQueueMessageAsync(string? connectionString, string? queueName, string message) - { - try - { - var queueClient = new QueueClient(connectionString, queueName); - - await queueClient.CreateIfNotExistsAsync(); - - await queueClient.SendMessageAsync(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message))); - _logger.LogInformation("[SendQueueMessageAsync] Sent message to queue: {queueName}", queueName); - } - catch (Exception ex) - { - _logger.LogError(ex, "[SendQueueMessageAsync] Failed to send message to queue: {queueName}", queueName); - } - } - - /// - /// Creates scoreboard entries for each player with random scores and stores them in the internal dictionary. - /// This method replaces Azure Table Storage operations by maintaining game data in memory using a thread-safe - /// Dictionary structure. Each game is stored with its unique game ID and contains all player scores. - /// - /// The storage account connection string. - /// The name of the table to store the scoreboard entries in. - /// A task that represents the asynchronous operation of creating and storing player scores. - private async Task CreateScoreboardEntriesAsync(string? connectionString, string? tableName) - { - try - { - var random = new Random(); - var playerScores = new List(); - var tableClient = new TableClient(connectionString, tableName); - await tableClient.CreateIfNotExistsAsync(); - var partitionKey = _gameId.ToString(); - - if (_playerNames == null || _playerNames.Length == 0) - { - _logger.LogWarning("[CreateScoreboardEntriesAsync] No player names configured. Skipping scoreboard entry creation."); - return; - } - - foreach (var name in _playerNames) - { - var score = Math.Max(0, random.Next(0, 101)); // Random number between 0 and 100, ensure >= 0 - var playerScore = new PlayerScore - { - Name = name, - Score = score - }; - - playerScores.Add(playerScore); - - var entity = new ScoreboardEntity - { - PartitionKey = partitionKey, - RowKey = Guid.NewGuid().ToString(), - GameId = _gameId, - PlayerName = name, - Score = score, - Timestamp = DateTimeOffset.UtcNow, - ETag = ETag.All - }; - - await tableClient.AddEntityAsync(entity); - - _logger.LogInformation("[CreateScoreboardEntriesAsync] Added scoreboard entry for {playerName} with score {score} in game {gameId}", name, score, _gameId); - } - - // Store the game data in the internal dictionary (thread-safe) - lock (_gameDataLock) - { - _gameData[_gameId] = playerScores; - } - - _logger.LogInformation("[CreateScoreboardEntriesAsync] Created {playerCount} scoreboard entries for game {gameId}.", _playerNames.Length, _gameId); - - // Simulate async work to maintain the async signature - await Task.CompletedTask; - } - catch (Exception ex) - { - _logger.LogError(ex, "[CreateScoreboardEntriesAsync] Failed to create scoreboard entries for game {gameId}", _gameId); - } - } +using System.Net; +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Azure.Storage.Blobs; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using Azure.Data.Tables; +using Azure; + +namespace LocalStack.Azure.Samples; + +/// +/// Manages game sessions and player interactions using Azure Functions with hybrid storage approach. +/// This class demonstrates a complete gaming scoreboard system with blob storage for game files, +/// queue processing for game events, table storage for winners, and internal dictionary for active game data. +/// The system processes GameStatusRequest/GameStatusResponse messages and maintains game state in memory +/// for improved performance while still utilizing Azure Storage services for persistence and messaging. +/// +public class GameSessionManager +{ + // Instance field for logging - keeps proper Azure Functions execution context + private readonly ILogger _logger; + + // Static configuration values - initialized once per application lifetime + private static string? _connectionString; + private static string? _inputQueueName; + private static string? _outputQueueName; + private static string? _triggerQueueName; + private static string? _inputContainerName; + private static string? _outputContainerName; + private static string? _inputTableName; + private static string? _outputTableName; + private static string[]? _playerNames; + private static bool _configurationValid; + private static int _gameId = 1; + + // Static dictionary to store game data in memory - game ID as key, list of player scores as value + // This replaces Azure Table Storage for demonstration purposes and provides faster access + private static readonly Dictionary> _gameData = new Dictionary>(); + private static readonly object _gameDataLock = new object(); + + // Static initialization - runs once per application lifetime + private static readonly Lazy _infrastructureInitialization = new Lazy(() => InitializeInfrastructureOnceAsync()); + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory used to create loggers for this class. + public GameSessionManager(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Ensures that Azure infrastructure (queues, containers, tables) is initialized exactly once + /// during the application lifetime. This method is thread-safe and idempotent. + /// + /// A task that completes when infrastructure initialization is finished. + private async Task EnsureInfrastructureInitializedAsync() + { + await _infrastructureInitialization.Value; + } + + /// + /// One-time initialization of Azure Storage infrastructure (queues, containers, tables). + /// This method runs exactly once per application lifetime and stores configuration values in static fields. + /// + /// A task representing the asynchronous initialization operation. + private static async Task InitializeInfrastructureOnceAsync() + { + try + { + // Create a temporary configuration instance for initialization + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("local.settings.json", optional: true); + var config = configBuilder.Build(); + + // Create a temporary logger for initialization + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger(); + + logger.LogInformation("[InitializeInfrastructureOnceAsync] Starting one-time infrastructure initialization..."); + + // Read and store configuration values in static fields with fallback defaults + _connectionString = config["STORAGE_ACCOUNT_CONNECTION_STRING"]; + _inputQueueName = config["INPUT_QUEUE_NAME"] ?? "input"; + _outputQueueName = config["OUTPUT_QUEUE_NAME"] ?? "output"; + _triggerQueueName = config["TRIGGER_QUEUE_NAME"] ?? "trigger"; + _inputContainerName = config["INPUT_STORAGE_CONTAINER_NAME"] ?? "input"; + _outputContainerName = config["OUTPUT_STORAGE_CONTAINER_NAME"] ?? "output"; + _inputTableName = config["INPUT_TABLE_NAME"] ?? "scoreboards"; + _outputTableName = config["OUTPUT_TABLE_NAME"] ?? "winners"; + _playerNames = config["PLAYER_NAMES"]?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Check if player names ae configured. If not use, use default names + if (_playerNames == null || _playerNames.Length == 0) + { + logger.LogWarning("[InitializeInfrastructureOnceAsync] PLAYER_NAMES configuration is missing or empty. Using default names."); + _playerNames = new[] { "Alice", "Anastasia", "Paolo", "Leo", "Mia" }; + } + + // Validate configuration and set the flag + _configurationValid = ValidateConfigurationValues(logger); + + if (_configurationValid && _connectionString != null) + { + // Initialize all infrastructure components + await InitializeQueuesAsync(_connectionString, logger, new[] { _inputQueueName, _outputQueueName, _triggerQueueName }.Where(q => q != null).ToArray()!); + await InitializeContainersAsync(_connectionString, logger, new[] { _inputContainerName, _outputContainerName }.Where(c => c != null).ToArray()!); + await InitializeTablesAsync(_connectionString, logger, new[] { _inputTableName, _outputTableName }.Where(t => t != null).ToArray()!); + + logger.LogInformation("[InitializeInfrastructureOnceAsync] Infrastructure initialization completed successfully."); + } + else + { + logger.LogError("[InitializeInfrastructureOnceAsync] Configuration validation failed. Infrastructure initialization aborted."); + } + } + catch (Exception ex) + { + // Log error but don't throw - let functions continue to work even if initialization fails + Console.WriteLine("[InitializeInfrastructureOnceAsync] Failed to initialize infrastructure: {0}", ex.Message); + _configurationValid = false; + } + } + + /// + /// Validates that all required configuration values are present and not empty. + /// With default values in place, only the connection string is mandatory. + /// + /// Logger for reporting validation errors. + /// True if all configuration values are valid, false otherwise. + private static bool ValidateConfigurationValues(ILogger logger) + { + bool isValid = true; + + // Connection string is the only truly required value - everything else has defaults + if (string.IsNullOrWhiteSpace(_connectionString)) + { + logger.LogError("[ValidateConfigurationValues] STORAGE_ACCOUNT_CONNECTION_STRING configuration value is missing and is required."); + isValid = false; + } + + // Log the configuration values being used (helpful for debugging) + if (isValid) + { + logger.LogInformation("[ValidateConfigurationValues] Configuration loaded successfully:"); + logger.LogInformation(" - Input Queue: {inputQueue}", _inputQueueName); + logger.LogInformation(" - Output Queue: {outputQueue}", _outputQueueName); + logger.LogInformation(" - Trigger Queue: {triggerQueue}", _triggerQueueName); + logger.LogInformation(" - Input Container: {inputContainer}", _inputContainerName); + logger.LogInformation(" - Output Container: {outputContainer}", _outputContainerName); + logger.LogInformation(" - Input Table: {inputTable}", _inputTableName); + logger.LogInformation(" - Output Table: {outputTable}", _outputTableName); + } + + return isValid; + } + + /// + /// Checks if configuration values have been successfully loaded and validated. + /// This method provides a fast runtime check without re-reading configuration. + /// With default values, this primarily checks if the connection string is available. + /// + /// True if configuration is valid and available, false otherwise. + private static bool IsConfigurationValid() + { + // Since we have defaults for all values except connection string, + // we only need to check the configuration validation flag and connection string + return _configurationValid && !string.IsNullOrWhiteSpace(_connectionString); + } + + /// + /// Static version of queue initialization for one-time setup. + /// + private static async Task InitializeQueuesAsync(string connectionString, ILogger logger, string[] queues) + { + try + { + foreach (var queueName in queues.Where(q => !string.IsNullOrWhiteSpace(q))) + { + var queueClient = new QueueClient(connectionString, queueName); + await queueClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeQueuesAsync] Initialized queue: {queueName}", queueName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeQueuesAsync] Failed to initialize queues."); + } + } + + /// + /// Static version of container initialization for one-time setup. + /// + private static async Task InitializeContainersAsync(string connectionString, ILogger logger, string[] containers) + { + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + foreach (var containerName in containers.Where(c => !string.IsNullOrWhiteSpace(c))) + { + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + await containerClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeContainersAsync] Initialized container: {containerName}", containerName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeContainersAsync] Failed to initialize containers."); + } + } + + /// + /// Static version of table initialization for one-time setup. + /// + private static async Task InitializeTablesAsync(string connectionString, ILogger logger, string[] tables) + { + try + { + foreach (var tableName in tables.Where(t => !string.IsNullOrWhiteSpace(t))) + { + var tableClient = new TableClient(connectionString, tableName); + await tableClient.CreateIfNotExistsAsync(); + logger.LogInformation("[InitializeTablesAsync] Initialized table: {tableName}", tableName); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[InitializeTablesAsync] Failed to initialize tables."); + } + } + + /// + /// Handles HTTP GET requests to retrieve player score for a specific game and player. + /// + /// The HTTP request data. + /// The game ID to retrieve scores for, provided in the route. + /// The player name to retrieve status for, provided in the route. + /// An HTTP response with player score information or an error message. + [Function("GetPlayerScore")] + public async Task GetPlayerScoreAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "player/{gameId}/{name}/status")] HttpRequestData request, int gameId, string name) + { + HttpResponseData response; + + // Log the incoming request + _logger.LogInformation("[GetPlayerScore] Received GET request with gameId = {gameId}, name = {name}.", gameId, name ?? "NULL"); + + // Validate the name parameter + if (name == null || string.IsNullOrWhiteSpace(name)) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("Invalid parameters: name parameter is required."); + return response; + } + + // Check if the game and player exist in the internal dictionary + PlayerScore? playerScore = null; + lock (_gameDataLock) + { + if (_gameData.TryGetValue(gameId, out var players)) + { + playerScore = players.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + } + + if (playerScore == null) + { + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {gameId} and player '{name}' tuple not found."); + return response; + } + + // Return the player score information + response = request.CreateResponse(HttpStatusCode.OK); + + // Create the response message + var outputObj = new PlayerScoreResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = gameId, + Name = playerScore.Name, + Score = playerScore.Score + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Write the response message to the HTTP response + await response.WriteStringAsync(outputMessage); + + // Log the successful processing + _logger.LogInformation("[GetPlayerScore] Processed request successfully with gameId = {gameId}, name = {name}, score = {score}.", gameId, name, playerScore.Score); + + // Return the HTTP response + return response; + } + + /// + /// Handles HTTP POST and PUT requests to retrieve game status information. + /// Accepts a GameStatusRequest in the request body and returns comprehensive game status + /// including winner determination and all player scores for the specified game. + /// + /// The HTTP request data containing a JSON-serialized GameStatusRequest. + /// An HTTP response with GameStatusResponse containing game status details, winner, and all players, or an error message if the game is not found or invalid. + [Function("CreateGameStatus")] + public async Task CreateGameStatusAsync([HttpTrigger(AuthorizationLevel.Function, "post", "put", Route = "game/session")] HttpRequestData request) + { + HttpResponseData response; + + // Log the incoming request method + _logger.LogInformation("[CreateGameStatus] Received {method} request.", request.Method); + + // Read the request body as a string + var requestBody = await request.ReadAsStringAsync(); + + // Validate that the request body is not empty + if (requestBody == null || string.IsNullOrWhiteSpace(requestBody)) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is required."); + return response; + } + + GameStatusRequest? requestMessage; + try + { + // Attempt to deserialize the request body into a GameStatusRequest object + requestMessage = JsonSerializer.Deserialize(requestBody); + } + catch (JsonException) + { + // Handle invalid JSON format + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: Request body is not in the proper format."); + return response; + } + + // Validate that the GameId property is present and valid + if (requestMessage == null || requestMessage.GameId <= 0) + { + response = request.CreateResponse(HttpStatusCode.BadRequest); + await response.WriteStringAsync("[CreateGameStatus] Invalid request message: 'GameId' is required and must be greater than 0."); + return response; + } + + // Check if the game exists in the internal dictionary + List? players = null; + bool gameFound = false; + lock (_gameDataLock) + { + gameFound = _gameData.TryGetValue(requestMessage.GameId, out players); + } + + if (!gameFound || players == null) + { + _logger.LogWarning("[CreateGameStatus] Game {gameId} not found in internal data store.", requestMessage.GameId); + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {requestMessage.GameId} not found."); + return response; + } + + if (players.Count == 0) + { + _logger.LogWarning("[CreateGameStatus] Game {gameId} has no player data.", requestMessage.GameId); + response = request.CreateResponse(HttpStatusCode.NotFound); + await response.WriteStringAsync($"Game {requestMessage.GameId} has no player data."); + return response; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Return a response if the request message is valid + response = request.CreateResponse(HttpStatusCode.OK); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = requestMessage.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Write the response message to the HTTP response + await response.WriteStringAsync(outputMessage); + + // Log the successful processing + _logger.LogInformation("[CreateGameStatus] Processed request successfully with gameId = {gameId}, winner = {winner}.", requestMessage.GameId, winner.Name); + + // Return the HTTP response + return response; + } + + /// + /// Processes uploaded game status files from blob storage and generates comprehensive game status responses. + /// Deserializes GameStatusRequest from blob content, retrieves game data from internal dictionary, + /// determines the winner, and returns a GameStatusResponse with complete game information. + /// + /// The blob content as byte array containing JSON-serialized GameStatusRequest. + /// The name of the blob file being processed. + /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players, or null if the input is invalid or game not found. + [Function("ProcessGameFile")] + [BlobOutput("%OUTPUT_STORAGE_CONTAINER_NAME%/{name}")] + public string? ProcessGameFile( + [BlobTrigger("%INPUT_STORAGE_CONTAINER_NAME%/{name}", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] byte[] blobBytes, + string name) + { + // Check that the blobBytes is not null or empty + if (blobBytes == null || blobBytes.Length == 0) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or null.", name); + return null; + } + + // Convert the byte array to a string + string json = System.Text.Encoding.UTF8.GetString(blobBytes); + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob is empty or invalid.", name); + return null; + } + + // Deserialize the JSON into a GameStatusRequest object + GameStatusRequest? gameStatusRequest = JsonSerializer.Deserialize(json); + + // Check that the request message is not null + if (gameStatusRequest == null) + { + _logger.LogError("[ProcessGameFile] Received [{name}] blob contains invalid GameStatusRequest.", name); + return null; + } + + // Check if the game exists in the internal dictionary + List? players = null; + lock (_gameDataLock) + { + if (!_gameData.TryGetValue(gameStatusRequest.GameId, out players)) + { + _logger.LogWarning("[ProcessGameFile] Game {gameId} not found in internal data store.", gameStatusRequest.GameId); + return null; + } + } + + if (players == null || players.Count == 0) + { + _logger.LogWarning("[ProcessGameFile] Game {gameId} has no player data.", gameStatusRequest.GameId); + return null; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = gameStatusRequest.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Log the successful processing of the blob + _logger.LogInformation("[ProcessGameFile] Processed blob [{name}] successfully for game {gameId}.", name, gameStatusRequest.GameId); + + // Return the response message + return outputMessage; + } + + /// + /// Handles game events from Azure Storage Queue and processes game status requests. + /// Deserializes GameStatusRequest from queue messages, retrieves game data from internal dictionary, + /// determines the winner, and returns a GameStatusResponse for further processing in the output queue. + /// + /// The incoming queue message containing JSON-serialized GameStatusRequest data. + /// The function execution context provided by the Azure Functions runtime. + /// + /// A JSON-formatted GameStatusResponse string containing game status, winner, and all players for output queue processing, or null if the input is invalid or game not found. + /// + [Function("HandleGameEvent")] + [QueueOutput("%OUTPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] + public string? HandleGameEvent([QueueTrigger("%INPUT_QUEUE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] QueueMessage message, FunctionContext context) + { + + // Check that the message and the body are not null or empty + if (message == null || string.IsNullOrWhiteSpace(message.Body?.ToString())) + { + _logger.LogError("[HandleGameEvent] Received queue message is null or empty."); + return null; + } + + var json = message.Body.ToString() ?? string.Empty; + + // Check that the JSON is not null or empty + if (string.IsNullOrEmpty(json)) + { + _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message is empty or invalid.", message.MessageId); + return null; + } + + // Deserialize the JSON into a GameStatusRequest object + GameStatusRequest? requestMessage = JsonSerializer.Deserialize(json); + + // Check that the request message is not null + if (requestMessage == null) + { + _logger.LogError("[HandleGameEvent] Received [{messageId}] queue message contains invalid GameStatusRequest.", message.MessageId); + return null; + } + + // Check if the game exists in the internal dictionary + List? players = null; + lock (_gameDataLock) + { + if (!_gameData.TryGetValue(requestMessage.GameId, out players)) + { + _logger.LogWarning("[HandleGameEvent] Game {gameId} not found in internal data store.", requestMessage.GameId); + // Return null for now, but could create an error response if needed + return null; + } + } + + if (players == null || players.Count == 0) + { + _logger.LogWarning("[HandleGameEvent] Game {gameId} has no player data.", requestMessage.GameId); + return null; + } + + // Find the winner (player with highest score) + var winner = players.OrderByDescending(p => p.Score).First(); + + // Create the response message + var outputObj = new GameStatusResponse + { + Date = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + GameId = requestMessage.GameId, + Winner = winner.Name, + Players = new List(players) // Create a copy of the list + }; + var outputMessage = JsonSerializer.Serialize(outputObj); + + // Log the successful processing of the queue message + _logger.LogInformation("[HandleGameEvent] Processed queue message [{messageId}] successfully for game {gameId}.", message.MessageId, requestMessage.GameId); + + // Return the response message + return outputMessage; + + } + + /// + /// Processes game scoreboards to determine winners and manage game results. + /// Retrieves scoreboard entries for a game, finds the highest score, and records the winner. + /// + /// The game ID from the queue message to filter scoreboard entries. + /// The scoreboard entities matching the specified game ID. + /// The winning ScoreboardEntity with the highest score, or null if no entities are found. + [Function("ProcessScoreboard")] + [TableOutput("%OUTPUT_TABLE_NAME%", Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] + public ScoreboardEntity? ProcessScoreboard( + [QueueTrigger("%TRIGGER_QUEUE_NAME%")] string gameId, + [TableInput("%INPUT_TABLE_NAME%", "{queueTrigger}", + Connection = "STORAGE_ACCOUNT_CONNECTION_STRING")] IEnumerable entities) + { + // Find the entity with the highest score + var winner = entities.OrderByDescending(e => e.Score).FirstOrDefault(); + _logger.LogInformation("[ProcessScoreboard] Processed game ID {gameId}. Winner: {winnerName} with score {score}.", gameId, winner?.PlayerName ?? "No winner", winner?.Score.ToString() ?? "N/A"); + + if (winner != null) + { + // Create a new entity for the output table with a new RowKey + var winnerEntity = new ScoreboardEntity + { + PartitionKey = $"winner-game-{int.Parse(gameId):D3}", + RowKey = Guid.NewGuid().ToString(), + GameId = winner.GameId, + PlayerName = winner.PlayerName, + Score = winner.Score, + Timestamp = DateTimeOffset.UtcNow, + ETag = ETag.All + }; + + return winnerEntity; + } + + return null; + } + + /// + /// Timer-triggered function that generates new game rounds with random player data and initiates the complete gaming workflow. + /// Creates GameStatusRequest objects, uploads them as blobs, sends queue messages, creates internal dictionary entries + /// with random player scores, and triggers the scoreboard processing pipeline. Runs every minute and on startup. + /// + /// Timer metadata containing schedule status and next occurrence information. + /// A task that represents the asynchronous game round generation operation. + [Function("CreateGame")] + [FixedDelayRetry(5, "00:00:10")] + public async Task CreateGameAsync([TimerTrigger("0 */1 * * * *", RunOnStartup = true)] TimerInfo timerInfo) + { + _logger.LogInformation("[CreateGameAsync] Triggered execution."); + + // Ensure infrastructure is initialized (runs only once per app lifetime) + await EnsureInfrastructureInitializedAsync(); + + // Fast configuration validation using pre-loaded static values + if (!IsConfigurationValid()) + { + _logger.LogError("[CreateGameAsync] Configuration is invalid or not loaded. Aborting function execution."); + return; + } + + if (_playerNames == null || _playerNames.Length == 0) + { + _logger.LogError("[CreateGameAsync] Player names are not configured. Aborting function execution."); + return; + } + + var random = new Random(); + var gameStatusRequest = new GameStatusRequest { GameId = _gameId }; + + // Serialize the request message to JSON + var message = JsonSerializer.Serialize(gameStatusRequest); + + // Log the generated message and configuration values + _logger.LogInformation("[CreateGameAsync] Generated message: {message}", message); + + // Create a unique blob name with the required format + var now = DateTime.UtcNow; + var blobFileName = $"game-{_gameId:D3}-status-{now:yyyy-MM-dd-HH-mm-ss}.json"; + + // Create scoreboard entries for all players using the updated method + await CreateScoreboardEntriesAsync(_connectionString, _inputTableName); + + // Upload blob to the input container + await UploadBlobAsync(_connectionString, _inputContainerName, blobFileName, message); + + // Send message to the input queue + await SendQueueMessageAsync(_connectionString, _inputQueueName, message); + + // Send message to the trigger queue + await SendQueueMessageAsync(_connectionString, _triggerQueueName, _gameId.ToString()); + + // Increment game ID for next execution + _gameId++; + + // Log the next scheduled timer occurrence + _logger.LogInformation("[CreateGameAsync] Function Ran. Next timer schedule = {nextSchedule}", timerInfo.ScheduleStatus?.Next); + } + + /// + /// Uploads a message as a blob to the specified Azure Storage container. + /// + /// The storage account connection string. + /// The name of the container to upload to. + /// The name of the blob file to create. + /// The message content to upload as blob data. + /// A task that represents the asynchronous upload operation. + private async Task UploadBlobAsync(string? connectionString, string? inputContainerName, string blobFileName, string message) + { + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + var blobContainerClient = blobServiceClient.GetBlobContainerClient(inputContainerName); + + await blobContainerClient.CreateIfNotExistsAsync(); + + var blobClient = blobContainerClient.GetBlobClient(blobFileName); + + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(message))) + { + await blobClient.UploadAsync(stream, overwrite: true); + } + _logger.LogInformation("[UploadBlobAsync] Uploaded blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[UploadBlobAsync] Failed to upload blob: {blobFileName} to container: {containerName}", blobFileName, inputContainerName); + } + } + + /// + /// Sends a message to the specified Azure Storage queue. + /// + /// The storage account connection string. + /// The name of the queue to send the message to. + /// The message content to send (will be Base64 encoded). + /// A task that represents the asynchronous send operation. + private async Task SendQueueMessageAsync(string? connectionString, string? queueName, string message) + { + try + { + var queueClient = new QueueClient(connectionString, queueName); + + await queueClient.CreateIfNotExistsAsync(); + + await queueClient.SendMessageAsync(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(message))); + _logger.LogInformation("[SendQueueMessageAsync] Sent message to queue: {queueName}", queueName); + } + catch (Exception ex) + { + _logger.LogError(ex, "[SendQueueMessageAsync] Failed to send message to queue: {queueName}", queueName); + } + } + + /// + /// Creates scoreboard entries for each player with random scores and stores them in the internal dictionary. + /// This method replaces Azure Table Storage operations by maintaining game data in memory using a thread-safe + /// Dictionary structure. Each game is stored with its unique game ID and contains all player scores. + /// + /// The storage account connection string. + /// The name of the table to store the scoreboard entries in. + /// A task that represents the asynchronous operation of creating and storing player scores. + private async Task CreateScoreboardEntriesAsync(string? connectionString, string? tableName) + { + try + { + var random = new Random(); + var playerScores = new List(); + var tableClient = new TableClient(connectionString, tableName); + await tableClient.CreateIfNotExistsAsync(); + var partitionKey = _gameId.ToString(); + + if (_playerNames == null || _playerNames.Length == 0) + { + _logger.LogWarning("[CreateScoreboardEntriesAsync] No player names configured. Skipping scoreboard entry creation."); + return; + } + + foreach (var name in _playerNames) + { + var score = Math.Max(0, random.Next(0, 101)); // Random number between 0 and 100, ensure >= 0 + var playerScore = new PlayerScore + { + Name = name, + Score = score + }; + + playerScores.Add(playerScore); + + var entity = new ScoreboardEntity + { + PartitionKey = partitionKey, + RowKey = Guid.NewGuid().ToString(), + GameId = _gameId, + PlayerName = name, + Score = score, + Timestamp = DateTimeOffset.UtcNow, + ETag = ETag.All + }; + + await tableClient.AddEntityAsync(entity); + + _logger.LogInformation("[CreateScoreboardEntriesAsync] Added scoreboard entry for {playerName} with score {score} in game {gameId}", name, score, _gameId); + } + + // Store the game data in the internal dictionary (thread-safe) + lock (_gameDataLock) + { + _gameData[_gameId] = playerScores; + } + + _logger.LogInformation("[CreateScoreboardEntriesAsync] Created {playerCount} scoreboard entries for game {gameId}.", _playerNames.Length, _gameId); + + // Simulate async work to maintain the async signature + await Task.CompletedTask; + } + catch (Exception ex) + { + _logger.LogError(ex, "[CreateScoreboardEntriesAsync] Failed to create scoreboard entries for game {gameId}", _gameId); + } + } } \ No newline at end of file diff --git a/samples/function-app-storage-http/dotnet/src/sample/Program.cs b/samples/function-app-storage-http/dotnet/src/sample/Program.cs index c76837e..51336f3 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/Program.cs +++ b/samples/function-app-storage-http/dotnet/src/sample/Program.cs @@ -1,7 +1,7 @@ -using Microsoft.Extensions.Hosting; - -var host = new HostBuilder() - .ConfigureFunctionsWorkerDefaults() - .Build(); - -host.Run(); +using Microsoft.Extensions.Hosting; + +var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + +host.Run(); diff --git a/samples/function-app-storage-http/dotnet/src/sample/sample.csproj b/samples/function-app-storage-http/dotnet/src/sample/sample.csproj index d7c6345..97195a5 100644 --- a/samples/function-app-storage-http/dotnet/src/sample/sample.csproj +++ b/samples/function-app-storage-http/dotnet/src/sample/sample.csproj @@ -1,33 +1,33 @@ - - - net10.0 - v4 - Exe - enable - enable - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - Never - - - - - + + + net10.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + \ No newline at end of file