diff --git a/mydocs/BasketService-documentation.md b/mydocs/BasketService-documentation.md new file mode 100644 index 000000000..fb51bbba9 --- /dev/null +++ b/mydocs/BasketService-documentation.md @@ -0,0 +1,237 @@ +# BasketService Documentation + +## Overview + +`BasketService` is a gRPC service implementation that manages shopping basket operations in the eShop application. It inherits from `Basket.BasketBase` and provides three core operations: retrieving, updating, and deleting user baskets. + +## Location + +**File Path:** `/src/Basket.API/Grpc/BasketService.cs` + +**Namespace:** `eShop.Basket.API.Grpc` + +## Dependencies + +The service relies on the following dependencies injected via constructor: + +- **`IBasketRepository`**: Repository for basket data persistence operations +- **`ILogger`**: Logger for diagnostic and debugging information + +## Class Structure + +```csharp +public class BasketService( + IBasketRepository repository, + ILogger logger) : Basket.BasketBase +``` + +## Public Methods + +### 1. GetBasket + +**Signature:** +```csharp +[AllowAnonymous] +public override async Task GetBasket( + GetBasketRequest request, + ServerCallContext context) +``` + +**Purpose:** Retrieves a customer's basket based on their user identity. + +**Authentication:** Allows anonymous access (though returns empty basket if user is not authenticated). + +**Behavior:** +- Extracts user identity from the gRPC server call context +- Returns empty basket if user identity is not available +- Logs debug information when debug logging is enabled +- Fetches basket data from repository using user ID +- Maps repository data to `CustomerBasketResponse` format +- Returns empty response if no basket exists + +**Returns:** `CustomerBasketResponse` containing basket items or empty response + +--- + +### 2. UpdateBasket + +**Signature:** +```csharp +public override async Task UpdateBasket( + UpdateBasketRequest request, + ServerCallContext context) +``` + +**Purpose:** Updates or creates a customer's basket with new items. + +**Authentication:** Requires authenticated user (throws exception if not authenticated). + +**Behavior:** +- Extracts and validates user identity from context +- Throws `RpcException` with `Unauthenticated` status if user is not authenticated +- Logs debug information about the update operation +- Converts request data to `CustomerBasket` model +- Updates basket in repository +- Throws `RpcException` with `NotFound` status if update fails +- Maps updated basket to response format + +**Returns:** `CustomerBasketResponse` containing updated basket items + +**Exceptions:** +- `RpcException(StatusCode.Unauthenticated)`: When user is not authenticated +- `RpcException(StatusCode.NotFound)`: When basket update fails + +--- + +### 3. DeleteBasket + +**Signature:** +```csharp +public override async Task DeleteBasket( + DeleteBasketRequest request, + ServerCallContext context) +``` + +**Purpose:** Deletes a customer's basket. + +**Authentication:** Requires authenticated user (throws exception if not authenticated). + +**Behavior:** +- Extracts and validates user identity from context +- Throws `RpcException` with `Unauthenticated` status if user is not authenticated +- Deletes basket from repository using user ID +- Returns empty success response + +**Returns:** `DeleteBasketResponse` (empty on success) + +**Exceptions:** +- `RpcException(StatusCode.Unauthenticated)`: When user is not authenticated + +--- + +## Private Helper Methods + +### ThrowNotAuthenticated + +```csharp +[DoesNotReturn] +private static void ThrowNotAuthenticated() +``` + +Throws a standardized `RpcException` indicating the caller is not authenticated. + +### ThrowBasketDoesNotExist + +```csharp +[DoesNotReturn] +private static void ThrowBasketDoesNotExist(string userId) +``` + +Throws a standardized `RpcException` indicating the basket for the given user ID does not exist. + +### MapToCustomerBasketResponse + +```csharp +private static CustomerBasketResponse MapToCustomerBasketResponse( + CustomerBasket customerBasket) +``` + +Maps internal `CustomerBasket` model to gRPC `CustomerBasketResponse` message format. + +### MapToCustomerBasket + +```csharp +private static CustomerBasket MapToCustomerBasket( + string userId, + UpdateBasketRequest customerBasketRequest) +``` + +Maps gRPC `UpdateBasketRequest` message to internal `CustomerBasket` model, associating it with the provided user ID. + +--- + +## Data Models + +### CustomerBasket (Internal Model) + +Properties: +- `BuyerId`: User identifier +- `Items`: Collection of basket items + +### BasketItem + +Properties: +- `ProductId`: Identifier of the product +- `Quantity`: Quantity of the product in the basket + +--- + +## Error Handling + +The service uses gRPC status codes for error responses: + +| Status Code | Scenario | Description | +|-------------|----------|-------------| +| `Unauthenticated` | User not authenticated | Thrown when operations requiring authentication are called without valid user identity | +| `NotFound` | Basket not found | Thrown when attempting to update a non-existent basket | + +--- + +## Logging + +The service implements debug-level logging for diagnostic purposes: + +- Logs basket retrieval operations with method name and user ID +- Logs basket update operations with method name and user ID +- Only logs when `LogLevel.Debug` is enabled + +--- + +## Security Considerations + +1. **Authentication:** Most operations require authenticated users except `GetBasket` which allows anonymous access +2. **User Identity:** User identity is extracted from the gRPC server call context using `context.GetUserIdentity()` +3. **Authorization:** Service ensures users can only access their own baskets by deriving user ID from authentication context + +--- + +## Usage Example + +This is a gRPC service, typically called from client applications using generated gRPC client code: + +```csharp +// Example client usage (pseudo-code) +var client = new Basket.BasketClient(channel); + +// Get basket +var getResponse = await client.GetBasketAsync(new GetBasketRequest()); + +// Update basket +var updateRequest = new UpdateBasketRequest(); +updateRequest.Items.Add(new BasketItem +{ + ProductId = 123, + Quantity = 2 +}); +var updateResponse = await client.UpdateBasketAsync(updateRequest); + +// Delete basket +var deleteResponse = await client.DeleteBasketAsync(new DeleteBasketRequest()); +``` + +--- + +## Related Components + +- **Repository:** `IBasketRepository` - Handles data persistence +- **Extensions:** `context.GetUserIdentity()` - Extension method for extracting user identity +- **Models:** `CustomerBasket`, `BasketItem` - Domain models in `eShop.Basket.API.Model` + +--- + +## Notes + +- The service follows the gRPC service pattern with strongly-typed request/response messages +- All async operations use proper async/await patterns +- Defensive programming with null checks and empty responses +- Uses `[DoesNotReturn]` attribute for exception-throwing helper methods diff --git a/mydocs/classed.md b/mydocs/classed.md new file mode 100644 index 000000000..7916e1ad0 --- /dev/null +++ b/mydocs/classed.md @@ -0,0 +1,29 @@ +```mermaid +classDiagram + class CustomerBasket { + +string BuyerId + +List~BasketItem~ Items + +CustomerBasket() + +CustomerBasket(string customerId) + } + + class BasketItem { + +string Id + +int ProductId + +string ProductName + +decimal UnitPrice + +decimal OldUnitPrice + +int Quantity + +string PictureUrl + +int Order + +Validate(ValidationContext validationContext) IEnumerable~ValidationResult~ + } + + class IValidatableObject { + <> + +Validate(ValidationContext validationContext) IEnumerable~ValidationResult~ + } + + CustomerBasket "1" *-- "0..*" BasketItem : contains + BasketItem ..|> IValidatableObject : implements +``` diff --git a/src/Basket.API/Model/BasketItem.cs b/src/Basket.API/Model/BasketItem.cs index 55c8a97c2..7e888522d 100644 --- a/src/Basket.API/Model/BasketItem.cs +++ b/src/Basket.API/Model/BasketItem.cs @@ -14,11 +14,16 @@ public IEnumerable Validate(ValidationContext validationContex { var results = new List(); - if (Quantity < 1) + if (Quantity < 1 || Quantity > 100) { results.Add(new ValidationResult("Invalid number of units", new[] { "Quantity" })); } + if (string.IsNullOrEmpty(ProductName)) + { + results.Add(new ValidationResult("Wir brauchen immer einen Produktnamen!", new[] { "ProductName" })); + } + return results; } } diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 000000000..ae99110c1 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,46 @@ +# eShop Azure Infrastructure (Terraform) + +This folder contains Terraform code to provision the reference architecture for eShop on Azure, as depicted in the provided architecture diagram. + +## Components Deployed +- Azure Resource Group +- Azure Virtual Network (VNET) with subnets: + - Private Link Subnet (10.205.238.0/24) + - APIM Subnet (10.205.239.0/24) + - AKS Subnet (10.205.240.0/20) +- Azure DNS Zone +- Azure Kubernetes Service (AKS) +- Azure Front Door with WAF +- Azure API Management (APIM) +- Azure Private Link Service + +## Usage + +1. Install the [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) and [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli). +2. Authenticate with Azure: + ```bash + az login + ``` +3. Initialize Terraform: + ```bash + terraform init + ``` +4. Review and customize variables in `variables.tf` as needed. +5. Plan the deployment: + ```bash + terraform plan + ``` +6. Apply the deployment: + ```bash + terraform apply + ``` + +## Notes +- This setup uses Terraform modules for VNET and AKS for best practices. +- You may need to adjust the AKS version, region, and other settings to match your requirements. +- Additional configuration may be required for production use (e.g., Front Door backend pools, APIM APIs, AKS node pools, etc.). + +--- + +**Diagram Reference:** +![Architecture Diagram](../img/ARCHITECTURE_DIAGRAM_PLACEHOLDER.png) diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 000000000..68dbbe638 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,95 @@ +# Azure infrastructure for eShop reference architecture + +provider "azurerm" { + features {} +} + +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0.0" + } + } + required_version = ">= 1.3.0" +} + +module "resource_group" { + source = "./modules/resource_group" + name = "eshop-rg" + location = var.location +} + +module "network" { + source = "./modules/network" + resource_group_name = module.resource_group.name + vnet_name = "eshop-spoke-vnet" + address_space = ["10.205.0.0/16"] + subnet_prefixes = [ + "10.205.238.0/24", # Private Link Subnet + "10.205.239.0/24", # APIM Subnet + "10.205.240.0/20" # AKS Subnet + ] + subnet_names = [ + "private-link-subnet", + "apim-subnet", + "aks-subnet" + ] +} + +module "dns_zone" { + source = "./modules/dns_zone" + name = var.dns_zone_name + resource_group_name = module.resource_group.name +} + +module "aks" { + source = "./modules/aks" + resource_group_name = module.resource_group.name + kubernetes_version = var.kubernetes_version + prefix = "eshop-aks" + vnet_subnet_id = module.network.vnet_subnets[2] + network_plugin = "azure" + node_pools = [ + { + name = "default" + vm_size = "Standard_DS2_v2" + node_count = 2 + } + ] +} + +module "frontdoor" { + source = "./modules/frontdoor" + name = "eshop-frontdoor" + resource_group_name = module.resource_group.name + sku_name = "Premium_AzureFrontDoor" +} + +module "waf" { + source = "./modules/waf" + name = "eshop-waf-policy" + resource_group_name = module.resource_group.name + sku_name = "Premium_AzureFrontDoor" +} + +module "apim" { + source = "./modules/apim" + name = "eshop-apim" + location = module.resource_group.location + resource_group_name = module.resource_group.name + publisher_name = "eshop" + publisher_email = var.apim_publisher_email + sku_name = "Developer_1" + virtual_network_type = "External" + subnet_id = module.network.vnet_subnets[1] +} + +module "private_link" { + source = "./modules/private_link" + name = "eshop-private-link" + location = module.resource_group.location + resource_group_name = module.resource_group.name + subnet_id = module.network.vnet_subnets[0] + load_balancer_frontend_ip_configuration_ids = [] +} diff --git a/terraform/modules/aks/main.tf b/terraform/modules/aks/main.tf new file mode 100644 index 000000000..799683219 --- /dev/null +++ b/terraform/modules/aks/main.tf @@ -0,0 +1,10 @@ +module "aks" { + source = "Azure/aks/azurerm" + version = "7.4.0" + resource_group_name = var.resource_group_name + kubernetes_version = var.kubernetes_version + prefix = var.prefix + vnet_subnet_id = var.vnet_subnet_id + network_plugin = var.network_plugin + node_pools = var.node_pools +} diff --git a/terraform/modules/aks/outputs.tf b/terraform/modules/aks/outputs.tf new file mode 100644 index 000000000..254b7cd6d --- /dev/null +++ b/terraform/modules/aks/outputs.tf @@ -0,0 +1,3 @@ +output "aks_name" { + value = module.aks.aks_name +} diff --git a/terraform/modules/aks/variables.tf b/terraform/modules/aks/variables.tf new file mode 100644 index 000000000..93936ce9a --- /dev/null +++ b/terraform/modules/aks/variables.tf @@ -0,0 +1,18 @@ +variable "resource_group_name" { + type = string +} +variable "kubernetes_version" { + type = string +} +variable "prefix" { + type = string +} +variable "vnet_subnet_id" { + type = string +} +variable "network_plugin" { + type = string +} +variable "node_pools" { + type = any +} diff --git a/terraform/modules/apim/main.tf b/terraform/modules/apim/main.tf new file mode 100644 index 000000000..9ecd28c47 --- /dev/null +++ b/terraform/modules/apim/main.tf @@ -0,0 +1,12 @@ +resource "azurerm_api_management" "this" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + publisher_name = var.publisher_name + publisher_email = var.publisher_email + sku_name = var.sku_name + virtual_network_type = var.virtual_network_type + virtual_network_configuration { + subnet_id = var.subnet_id + } +} diff --git a/terraform/modules/apim/outputs.tf b/terraform/modules/apim/outputs.tf new file mode 100644 index 000000000..a4ec15329 --- /dev/null +++ b/terraform/modules/apim/outputs.tf @@ -0,0 +1,3 @@ +output "name" { + value = azurerm_api_management.this.name +} diff --git a/terraform/modules/apim/variables.tf b/terraform/modules/apim/variables.tf new file mode 100644 index 000000000..8ac0c0e96 --- /dev/null +++ b/terraform/modules/apim/variables.tf @@ -0,0 +1,24 @@ +variable "name" { + type = string +} +variable "location" { + type = string +} +variable "resource_group_name" { + type = string +} +variable "publisher_name" { + type = string +} +variable "publisher_email" { + type = string +} +variable "sku_name" { + type = string +} +variable "virtual_network_type" { + type = string +} +variable "subnet_id" { + type = string +} diff --git a/terraform/modules/dns_zone/main.tf b/terraform/modules/dns_zone/main.tf new file mode 100644 index 000000000..4ecef8be5 --- /dev/null +++ b/terraform/modules/dns_zone/main.tf @@ -0,0 +1,4 @@ +resource "azurerm_dns_zone" "this" { + name = var.name + resource_group_name = var.resource_group_name +} diff --git a/terraform/modules/dns_zone/outputs.tf b/terraform/modules/dns_zone/outputs.tf new file mode 100644 index 000000000..489e5ec47 --- /dev/null +++ b/terraform/modules/dns_zone/outputs.tf @@ -0,0 +1,3 @@ +output "name" { + value = azurerm_dns_zone.this.name +} diff --git a/terraform/modules/dns_zone/variables.tf b/terraform/modules/dns_zone/variables.tf new file mode 100644 index 000000000..821ba8796 --- /dev/null +++ b/terraform/modules/dns_zone/variables.tf @@ -0,0 +1,6 @@ +variable "name" { + type = string +} +variable "resource_group_name" { + type = string +} diff --git a/terraform/modules/frontdoor/main.tf b/terraform/modules/frontdoor/main.tf new file mode 100644 index 000000000..c140fe379 --- /dev/null +++ b/terraform/modules/frontdoor/main.tf @@ -0,0 +1,5 @@ +resource "azurerm_cdn_frontdoor_profile" "this" { + name = var.name + resource_group_name = var.resource_group_name + sku_name = var.sku_name +} diff --git a/terraform/modules/frontdoor/outputs.tf b/terraform/modules/frontdoor/outputs.tf new file mode 100644 index 000000000..7ae71e694 --- /dev/null +++ b/terraform/modules/frontdoor/outputs.tf @@ -0,0 +1,3 @@ +output "endpoint" { + value = azurerm_cdn_frontdoor_profile.this.endpoint +} diff --git a/terraform/modules/frontdoor/variables.tf b/terraform/modules/frontdoor/variables.tf new file mode 100644 index 000000000..f1be388e0 --- /dev/null +++ b/terraform/modules/frontdoor/variables.tf @@ -0,0 +1,9 @@ +variable "name" { + type = string +} +variable "resource_group_name" { + type = string +} +variable "sku_name" { + type = string +} diff --git a/terraform/modules/network/main.tf b/terraform/modules/network/main.tf new file mode 100644 index 000000000..44eafad71 --- /dev/null +++ b/terraform/modules/network/main.tf @@ -0,0 +1,9 @@ +module "vnet" { + source = "Azure/vnet/azurerm" + version = "4.0.0" + resource_group_name = var.resource_group_name + vnet_name = var.vnet_name + address_space = var.address_space + subnet_prefixes = var.subnet_prefixes + subnet_names = var.subnet_names +} diff --git a/terraform/modules/network/outputs.tf b/terraform/modules/network/outputs.tf new file mode 100644 index 000000000..98fd7eae2 --- /dev/null +++ b/terraform/modules/network/outputs.tf @@ -0,0 +1,6 @@ +output "vnet_id" { + value = module.vnet.vnet_id +} +output "vnet_subnets" { + value = module.vnet.vnet_subnets +} diff --git a/terraform/modules/network/variables.tf b/terraform/modules/network/variables.tf new file mode 100644 index 000000000..60c5eb544 --- /dev/null +++ b/terraform/modules/network/variables.tf @@ -0,0 +1,15 @@ +variable "resource_group_name" { + type = string +} +variable "vnet_name" { + type = string +} +variable "address_space" { + type = list(string) +} +variable "subnet_prefixes" { + type = list(string) +} +variable "subnet_names" { + type = list(string) +} diff --git a/terraform/modules/private_link/main.tf b/terraform/modules/private_link/main.tf new file mode 100644 index 000000000..ac3f434b8 --- /dev/null +++ b/terraform/modules/private_link/main.tf @@ -0,0 +1,7 @@ +resource "azurerm_private_link_service" "this" { + name = var.name + location = var.location + resource_group_name = var.resource_group_name + subnet_id = var.subnet_id + load_balancer_frontend_ip_configuration_ids = var.load_balancer_frontend_ip_configuration_ids +} diff --git a/terraform/modules/private_link/outputs.tf b/terraform/modules/private_link/outputs.tf new file mode 100644 index 000000000..75413d841 --- /dev/null +++ b/terraform/modules/private_link/outputs.tf @@ -0,0 +1,3 @@ +output "name" { + value = azurerm_private_link_service.this.name +} diff --git a/terraform/modules/private_link/variables.tf b/terraform/modules/private_link/variables.tf new file mode 100644 index 000000000..c2f1688f2 --- /dev/null +++ b/terraform/modules/private_link/variables.tf @@ -0,0 +1,16 @@ +variable "name" { + type = string +} +variable "location" { + type = string +} +variable "resource_group_name" { + type = string +} +variable "subnet_id" { + type = string +} +variable "load_balancer_frontend_ip_configuration_ids" { + type = list(string) + default = [] +} diff --git a/terraform/modules/resource_group/main.tf b/terraform/modules/resource_group/main.tf new file mode 100644 index 000000000..87031410a --- /dev/null +++ b/terraform/modules/resource_group/main.tf @@ -0,0 +1,4 @@ +resource "azurerm_resource_group" "this" { + name = var.name + location = var.location +} diff --git a/terraform/modules/resource_group/outputs.tf b/terraform/modules/resource_group/outputs.tf new file mode 100644 index 000000000..849b7b317 --- /dev/null +++ b/terraform/modules/resource_group/outputs.tf @@ -0,0 +1,7 @@ +output "name" { + value = azurerm_resource_group.this.name +} + +output "location" { + value = azurerm_resource_group.this.location +} diff --git a/terraform/modules/resource_group/variables.tf b/terraform/modules/resource_group/variables.tf new file mode 100644 index 000000000..67a3d30e7 --- /dev/null +++ b/terraform/modules/resource_group/variables.tf @@ -0,0 +1,9 @@ +variable "name" { + description = "Resource group name." + type = string +} + +variable "location" { + description = "Azure region." + type = string +} diff --git a/terraform/modules/waf/main.tf b/terraform/modules/waf/main.tf new file mode 100644 index 000000000..892b57e76 --- /dev/null +++ b/terraform/modules/waf/main.tf @@ -0,0 +1,5 @@ +resource "azurerm_cdn_frontdoor_firewall_policy" "this" { + name = var.name + resource_group_name = var.resource_group_name + sku_name = var.sku_name +} diff --git a/terraform/modules/waf/outputs.tf b/terraform/modules/waf/outputs.tf new file mode 100644 index 000000000..1a653b2d2 --- /dev/null +++ b/terraform/modules/waf/outputs.tf @@ -0,0 +1,3 @@ +output "name" { + value = azurerm_cdn_frontdoor_firewall_policy.this.name +} diff --git a/terraform/modules/waf/variables.tf b/terraform/modules/waf/variables.tf new file mode 100644 index 000000000..f1be388e0 --- /dev/null +++ b/terraform/modules/waf/variables.tf @@ -0,0 +1,9 @@ +variable "name" { + type = string +} +variable "resource_group_name" { + type = string +} +variable "sku_name" { + type = string +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 000000000..87c692485 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "resource_group_name" { + value = azurerm_resource_group.eshop.name +} + +output "aks_cluster_name" { + value = module.aks.aks_name +} + +output "frontdoor_endpoint" { + value = azurerm_cdn_frontdoor_profile.eshop.endpoint +} + +output "apim_name" { + value = azurerm_api_management.eshop.name +} + +output "private_link_service_name" { + value = azurerm_private_link_service.eshop.name +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 000000000..223ef4c05 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,23 @@ +variable "location" { + description = "Azure region to deploy resources." + type = string + default = "westeurope" +} + +variable "dns_zone_name" { + description = "DNS zone name (e.g., contoso.com)." + type = string + default = "contoso.com" +} + +variable "kubernetes_version" { + description = "AKS Kubernetes version." + type = string + default = "1.29.0" +} + +variable "apim_publisher_email" { + description = "Email for APIM publisher." + type = string + default = "admin@contoso.com" +}