Level: Intermediate
Technologies: Azure Functions v4 / C# / Azure API Management / OpenAPI
About this guide: This document is a comprehensive reference on deploying REST APIs with Azure Functions and Azure API Management (APIM). It covers RESTful API design, APIM integration, OpenAPI documentation generation, security, versioning, and deployment best practices.
Table of Contents
- REST Principles and Azure Functions as API Backend
- Designing a Complete REST API
- Azure API Management (APIM)
- OpenAPI / Swagger Documentation
- REST API Security
- API Versioning and Evolution
- API Deployment and CI/CD
- API Monitoring and Analytics
- Best Practices and Patterns
- Glossary
Module 1 – REST Principles and Azure Functions as API Backend {#module-1}
REST Principles (Representational State Transfer)
REST is a lightweight communication architecture that allows different application components to communicate with each other via HTTP. Its widespread adoption stems from its simplicity, universality, and natural alignment with web protocols.
The 6 REST architectural constraints:
| Constraint | Description | Impact |
|---|---|---|
| Stateless | Each request contains all necessary information; no server-side session | Easier horizontal scaling |
| Client-Server | Clear separation between client (UI) and server (data) | Development independence |
| Cacheable | Responses indicate whether they can be cached | Performance and reduced load |
| Uniform Interface | Standardized interface (resources, HTTP verbs, representation) | Universal interoperability |
| Layered System | Client doesn’t know if it’s talking directly to the server (proxies, APIM) | Architectural flexibility |
| Code on Demand | Optional: transfer of executable code to the client | Extensibility |
HTTP Methods and CRUD Operations
flowchart LR
subgraph "HTTP Methods → CRUD Operations"
G[GET] -->|READ| R[Read a resource]
P[POST] -->|CREATE| C[Create a resource]
PU[PUT] -->|REPLACE| U[Replace completely]
PA[PATCH] -->|UPDATE| UP[Partial update]
D[DELETE] -->|DELETE| DEL[Delete a resource]
HEAD[HEAD] -->|METADATA| M[Get headers without body]
OPT[OPTIONS] -->|CAPABILITIES| CAP[Discover supported methods]
end
style G fill:#107C10,color:#fff
style P fill:#0078D4,color:#fff
style PU fill:#FFB900,color:#000
style PA fill:#FF8C00,color:#fff
style D fill:#D83B01,color:#fff
REST URL Naming Conventions:
| Convention | Example | Best Practice |
|---|---|---|
| Lowercase names | /api/products | ✅ Yes |
| Plural names | /api/products | ✅ Yes |
| No verbs in URL | /api/products/{id} | ✅ Yes |
| Use hyphens | /api/product-categories | ✅ Yes |
| Logical hierarchy | /api/orders/{orderId}/items | ✅ Yes |
| Verbs in URL | /api/getProducts | ❌ No |
| PascalCase in URL | /api/Products | ❌ No |
Essential HTTP Status Codes:
| Code | Meaning | Usage |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH (existing resource) |
| 201 Created | Resource created | Successful POST |
| 204 No Content | Success without body | DELETE, PUT without return |
| 400 Bad Request | Invalid request | Validation failed |
| 401 Unauthorized | Not authenticated | Missing or expired token |
| 403 Forbidden | Not authorized | Authenticated but no permission |
| 404 Not Found | Resource absent | Non-existent resource |
| 409 Conflict | Conflict | Resource already exists |
| 422 Unprocessable Entity | Invalid business logic | Business constraints not met |
| 429 Too Many Requests | Rate limit exceeded | APIM throttling |
| 500 Internal Server Error | Server error | Unhandled exception |
| 503 Service Unavailable | Service unavailable | Maintenance, overload |
Azure Functions as REST Backend: Architecture
sequenceDiagram
participant C as Client\n(Browser/Mobile/API)
participant APIM as Azure API Management
participant AF as Azure Functions
participant DB as Database
C->>APIM: HTTPS Request\nGET /api/v1/products
APIM->>APIM: JWT Validation
APIM->>APIM: Rate limiting check
APIM->>APIM: Request transformation
APIM->>AF: GET /api/products\n(with x-functions-key)
AF->>DB: Query SELECT * FROM Products
DB-->>AF: Results
AF-->>APIM: 200 OK + JSON payload
APIM->>APIM: Response transformation
APIM->>APIM: Log analytics
APIM-->>C: 200 OK + JSON + CORS headers
Module 2 – Designing a Complete REST API with Azure Functions {#module-2}
REST API Project Structure
GloboTicket.Api/
├── GloboTicket.Api.csproj
├── Program.cs
├── host.json
├── local.settings.json
├── Functions/
│ ├── ProductsFunction.cs ← Products CRUD
│ ├── OrdersFunction.cs ← Orders CRUD
│ ├── CategoriesFunction.cs ← Categories CRUD
│ └── HealthFunction.cs ← Health check
├── Models/
│ ├── Requests/
│ │ ├── CreateProductRequest.cs
│ │ └── UpdateProductRequest.cs
│ ├── Responses/
│ │ ├── ProductResponse.cs
│ │ └── ApiResponse.cs
│ └── Validators/
│ └── ProductValidator.cs
├── Services/
│ ├── IProductService.cs
│ └── ProductService.cs
└── Extensions/
└── HttpRequestDataExtensions.cs
Complete CRUD for a “Products” Resource
using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace GloboTicket.Api.Functions;
/// <summary>
/// Complete REST API for product management (concert tickets)
/// Base URL: /api/products
/// </summary>
public class ProductsFunction
{
private readonly ILogger<ProductsFunction> _logger;
private readonly IProductService _productService;
public ProductsFunction(
ILogger<ProductsFunction> logger,
IProductService productService)
{
_logger = logger;
_productService = productService;
}
// ================================
// GET /api/products
// Retrieves product list with filtering and pagination
// ================================
[Function("GetProducts")]
public async Task<HttpResponseData> GetProducts(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "products")]
HttpRequestData req)
{
_logger.LogInformation("GET /api/products requested");
// Pagination and filtering parameters from query string
int page = int.TryParse(req.Query["page"], out var p) ? Math.Max(1, p) : 1;
int pageSize = int.TryParse(req.Query["pageSize"], out var ps) ? Math.Clamp(ps, 1, 100) : 20;
string? category = req.Query["category"];
string? search = req.Query["search"];
string sort = req.Query["sort"] ?? "name";
var result = await _productService.GetProductsAsync(new ProductQueryParams
{
Page = page,
PageSize = pageSize,
Category = category,
Search = search,
Sort = sort
});
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("X-Total-Count", result.TotalCount.ToString());
response.Headers.Add("X-Page", page.ToString());
response.Headers.Add("X-Page-Size", pageSize.ToString());
response.Headers.Add("Cache-Control", "public, max-age=300"); // 5 min cache
await response.WriteAsJsonAsync(result);
return response;
}
// ================================
// GET /api/products/{id:guid}
// Retrieves a product by its ID
// ================================
[Function("GetProductById")]
public async Task<HttpResponseData> GetProductById(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "products/{id:guid}")]
HttpRequestData req,
Guid id)
{
_logger.LogInformation("GET /api/products/{Id}", id);
var product = await _productService.GetByIdAsync(id);
if (product is null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new ApiErrorResponse
{
Error = "ProductNotFound",
Message = $"Product with ID '{id}' not found.",
StatusCode = 404
});
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("ETag", $"\"{product.Version}\"");
response.Headers.Add("Last-Modified", product.UpdatedAt.ToString("R"));
await response.WriteAsJsonAsync(product);
return response;
}
// ================================
// POST /api/products
// Creates a new product
// ================================
[Function("CreateProduct")]
public async Task<HttpResponseData> CreateProduct(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "products")]
HttpRequestData req)
{
_logger.LogInformation("POST /api/products");
var createRequest = await req.ReadFromJsonAsync<CreateProductRequest>();
// Validation
if (createRequest is null)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new ApiErrorResponse
{
Error = "InvalidBody",
Message = "Request body is invalid or missing.",
StatusCode = 400
});
return badReq;
}
var validationErrors = ValidateCreateProductRequest(createRequest);
if (validationErrors.Count > 0)
{
var validationError = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await validationError.WriteAsJsonAsync(new ApiValidationErrorResponse
{
Error = "ValidationFailed",
Message = "Data validation failed.",
StatusCode = 422,
Errors = validationErrors
});
return validationError;
}
// Check for duplicate (idempotency via name + category)
var existing = await _productService.FindByNameAndCategoryAsync(
createRequest.Name, createRequest.CategoryId);
if (existing is not null)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new ApiErrorResponse
{
Error = "ProductAlreadyExists",
Message = $"A product with name '{createRequest.Name}' already exists in this category.",
StatusCode = 409
});
return conflict;
}
var createdProduct = await _productService.CreateAsync(createRequest);
var response = req.CreateResponse(HttpStatusCode.Created);
// Location header points to the new resource
response.Headers.Add("Location", $"/api/products/{createdProduct.Id}");
await response.WriteAsJsonAsync(createdProduct);
return response;
}
// ================================
// PUT /api/products/{id:guid}
// Completely replaces a product (upsert)
// ================================
[Function("UpdateProduct")]
public async Task<HttpResponseData> UpdateProduct(
[HttpTrigger(AuthorizationLevel.Function, "put", Route = "products/{id:guid}")]
HttpRequestData req,
Guid id)
{
_logger.LogInformation("PUT /api/products/{Id}", id);
var updateRequest = await req.ReadFromJsonAsync<UpdateProductRequest>();
if (updateRequest is null)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
return badReq;
}
var existingProduct = await _productService.GetByIdAsync(id);
if (existingProduct is null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new ApiErrorResponse
{
Error = "ProductNotFound",
Message = $"Product '{id}' not found.",
StatusCode = 404
});
return notFound;
}
// ETag check for optimistic concurrency
if (req.Headers.TryGetValues("If-Match", out var etags))
{
var ifMatch = etags.FirstOrDefault()?.Trim('"');
if (ifMatch != null && ifMatch != existingProduct.Version.ToString())
{
var preconditionFailed = req.CreateResponse(HttpStatusCode.PreconditionFailed);
await preconditionFailed.WriteAsJsonAsync(new ApiErrorResponse
{
Error = "ResourceModified",
Message = "The product has been modified. Reload before saving.",
StatusCode = 412
});
return preconditionFailed;
}
}
var updatedProduct = await _productService.UpdateAsync(id, updateRequest);
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("ETag", $"\"{updatedProduct.Version}\"");
await response.WriteAsJsonAsync(updatedProduct);
return response;
}
// ================================
// PATCH /api/products/{id:guid}
// Partial update of a product
// ================================
[Function("PatchProduct")]
public async Task<HttpResponseData> PatchProduct(
[HttpTrigger(AuthorizationLevel.Function, "patch", Route = "products/{id:guid}")]
HttpRequestData req,
Guid id)
{
_logger.LogInformation("PATCH /api/products/{Id}", id);
var patchRequest = await req.ReadFromJsonAsync<Dictionary<string, object?>>();
if (patchRequest is null || patchRequest.Count == 0)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new ApiErrorResponse
{
Error = "EmptyPatch",
Message = "The patch contains no fields to modify.",
StatusCode = 400
});
return badReq;
}
var product = await _productService.GetByIdAsync(id);
if (product is null)
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
var patchedProduct = await _productService.PatchAsync(id, patchRequest);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(patchedProduct);
return response;
}
// ================================
// DELETE /api/products/{id:guid}
// Deletes a product
// ================================
[Function("DeleteProduct")]
public async Task<HttpResponseData> DeleteProduct(
[HttpTrigger(AuthorizationLevel.Function, "delete", Route = "products/{id:guid}")]
HttpRequestData req,
Guid id)
{
_logger.LogInformation("DELETE /api/products/{Id}", id);
var product = await _productService.GetByIdAsync(id);
if (product is null)
{
return req.CreateResponse(HttpStatusCode.NotFound);
}
await _productService.DeleteAsync(id);
// 204 No Content: success without response body
return req.CreateResponse(HttpStatusCode.NoContent);
}
// ================================
// Health Check : GET /api/health
// ================================
[Function("HealthCheck")]
public async Task<HttpResponseData> HealthCheck(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "health")]
HttpRequestData req)
{
var health = await _productService.CheckHealthAsync();
var statusCode = health.IsHealthy ? HttpStatusCode.OK : HttpStatusCode.ServiceUnavailable;
var response = req.CreateResponse(statusCode);
await response.WriteAsJsonAsync(new
{
status = health.IsHealthy ? "healthy" : "unhealthy",
timestamp = DateTime.UtcNow,
checks = health.ComponentChecks
});
return response;
}
// ================================
// Utility methods
// ================================
private static List<ValidationError> ValidateCreateProductRequest(CreateProductRequest request)
{
var errors = new List<ValidationError>();
if (string.IsNullOrWhiteSpace(request.Name))
errors.Add(new ValidationError("name", "Name is required"));
else if (request.Name.Length > 200)
errors.Add(new ValidationError("name", "Name cannot exceed 200 characters"));
if (request.Price <= 0)
errors.Add(new ValidationError("price", "Price must be greater than 0"));
if (request.Price > 10000)
errors.Add(new ValidationError("price", "Price cannot exceed $10,000"));
if (request.CategoryId == Guid.Empty)
errors.Add(new ValidationError("categoryId", "Category is required"));
return errors;
}
}
Request and Response Models
// Requests
public record CreateProductRequest(
string Name,
string Description,
decimal Price,
Guid CategoryId,
int AvailableTickets,
DateTime EventDate,
string Venue);
public record UpdateProductRequest(
string Name,
string Description,
decimal Price,
Guid CategoryId,
int AvailableTickets,
DateTime EventDate,
string Venue);
// Standardized responses
public record ProductResponse(
Guid Id,
string Name,
string Description,
decimal Price,
string CategoryName,
int AvailableTickets,
DateTime EventDate,
string Venue,
long Version,
DateTime CreatedAt,
DateTime UpdatedAt);
public record ApiErrorResponse
{
public string Error { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public int StatusCode { get; init; }
public string? TraceId { get; init; }
}
public record ApiValidationErrorResponse : ApiErrorResponse
{
public List<ValidationError> Errors { get; init; } = new();
}
public record ValidationError(string Field, string Message);
public record PagedResponse<T>
{
public IEnumerable<T> Items { get; init; } = Enumerable.Empty<T>();
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
}
Module 3 – Azure API Management (APIM) {#module-3}
Azure API Management Overview
Azure API Management is a fully managed service that provides an abstraction layer between clients consuming your APIs and your backends (in this case, Azure Functions). It offers a complete suite of features for API management, security, and observability.
flowchart TB
subgraph "Clients"
Web[Web Application]
Mobile[Mobile Application]
Partner[Partners]
Internal[Internal Use]
end
subgraph "Azure API Management"
GW[Gateway\nRoute, Auth, Transform]
DP[Developer Portal\nDocumentation, Tests]
MGMT[Management Plane\nConfiguration, Policies]
end
subgraph "Backends"
AF1[Azure Function App 1\nWidgets API]
AF2[Azure Function App 2\nOrders API]
OnPrem[On-Premises API]
External[External API]
end
Web & Mobile & Partner & Internal --> GW
GW --> AF1
GW --> AF2
GW --> OnPrem
GW --> External
DP -.->|Documentation| Web & Mobile
MGMT -.->|Configure| GW
style GW fill:#0078D4,color:#fff
style DP fill:#0078D4,color:#fff
APIM Tiers and Their Differences
| Tier | vCores | Units | SLA | Features | Use Case |
|---|---|---|---|---|---|
| Developer | 1 | 1 | Non-production | All except SLA | Dev/Test only |
| Basic | 1 | 1-4 | 99.95% | Standard | Small APIs |
| Standard | 2 | 1-4 | 99.95% | + User-defined gateways | Medium APIs |
| Premium | 4 | 1-12 | 99.99% | + Multi-region, VNet | Enterprise |
| Consumption | Serverless | Pay/call | 99.95% | Limited | Prototyping |
| Basic v2 | - | 1+ | 99.95% | Enhanced | Light load |
| Standard v2 | - | 1+ | 99.99% | + VNet Injection | Medium load |
Importing Azure Functions into APIM
sequenceDiagram
participant Admin as Administrator
participant APIM as Azure API Management
participant AF as Azure Function App
Admin->>APIM: Create new API
Admin->>APIM: Select "Azure Function"
APIM->>AF: Read available Function Keys
AF-->>APIM: Return keys
APIM->>AF: Read available HTTP Triggers
AF-->>APIM: Return endpoints
Admin->>APIM: Configure API URL suffix
Admin->>APIM: Select functions to import
APIM->>APIM: Create API operations
APIM->>APIM: Configure authentication policy
APIM-->>Admin: API created successfully
Via Azure CLI:
# Create an APIM instance
az apim create \
--name apim-globoticket-prod \
--resource-group rg-globoticket-prod \
--publisher-name "GloboTicket Inc." \
--publisher-email "api-admin@globoticket.com" \
--sku-name Developer \
--location eastus
# Import an Azure Function App as an API
az apim api import \
--resource-group rg-globoticket-prod \
--service-name apim-globoticket-prod \
--api-id products-api \
--path /v1/products \
--specification-format OpenApi \
--specification-url "https://func-globoticket-prod.azurewebsites.net/api/swagger.json?code=<key>"
# Create a subscription for a client
az apim subscription create \
--resource-group rg-globoticket-prod \
--service-name apim-globoticket-prod \
--name mobile-app-subscription \
--display-name "GloboTicket Mobile Application" \
--scope "/apis/products-api"
APIM Policies
APIM policies are XML pipelines applied to inbound and outbound requests. They allow you to transform, secure, and enrich APIs without modifying the backend code.
<!-- Complete global policy for GloboTicket API -->
<policies>
<!-- ==================== INBOUND ==================== -->
<inbound>
<!-- Rate limiting per subscription -->
<rate-limit-by-key
calls="1000"
renewal-period="60"
counter-key="@(context.Subscription.Id)"
increment-condition="@(context.Response.StatusCode == 200)"
remaining-calls-header-name="X-RateLimit-Remaining"
retry-after-header-name="Retry-After"/>
<!-- Monthly quota -->
<quota-by-key
calls="100000"
renewal-period="2592000"
counter-key="@(context.Subscription.Id)"/>
<!-- Entra ID JWT token validation -->
<validate-jwt
header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Invalid or expired authorization token">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration"/>
<audiences>
<audience>api://globoticket-api</audience>
</audiences>
<required-claims>
<claim name="scp" match="any">
<value>products.read</value>
<value>products.write</value>
</claim>
</required-claims>
</validate-jwt>
<!-- Add Function key as secret -->
<set-header name="x-functions-key" exists-action="override">
<value>{{function-app-key}}</value>
</set-header>
<!-- Transform URL: /v1/products → /api/products -->
<rewrite-uri template="/api/products" copy-unmatched-params="true"/>
<!-- Add context headers -->
<set-header name="X-Correlation-ID" exists-action="skip">
<value>@(Guid.NewGuid().ToString())</value>
</set-header>
<set-header name="X-Forwarded-For" exists-action="override">
<value>@(context.Request.IpAddress)</value>
</set-header>
<!-- CORS -->
<cors allow-credentials="true">
<allowed-origins>
<origin>https://www.globoticket.com</origin>
<origin>https://app.globoticket.com</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>POST</method>
<method>PUT</method>
<method>PATCH</method>
<method>DELETE</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>Authorization</header>
<header>Content-Type</header>
<header>Accept</header>
</allowed-headers>
</cors>
</inbound>
<!-- ==================== BACKEND ==================== -->
<backend>
<!-- Automatic retry on 5xx errors -->
<retry condition="@(context.Response.StatusCode >= 500)"
count="3"
interval="2"
delta="2">
<forward-request timeout="30"/>
</retry>
</backend>
<!-- ==================== OUTBOUND ==================== -->
<outbound>
<!-- Remove sensitive headers from response -->
<set-header name="X-Powered-By" exists-action="delete"/>
<set-header name="X-AspNet-Version" exists-action="delete"/>
<set-header name="Server" exists-action="delete"/>
<!-- Add standard API headers -->
<set-header name="X-API-Version" exists-action="override">
<value>1.0</value>
</set-header>
<!-- Cache GET responses -->
<cache-store duration="300"/>
</outbound>
<!-- ==================== ON-ERROR ==================== -->
<on-error>
<set-variable name="errorMessage"
value="@(context.LastError.Message)"/>
<return-response>
<set-status code="@(context.Response.StatusCode)"
reason="@(context.Response.StatusReason)"/>
<set-body>@{
return new JObject(
new JProperty("error", context.LastError.Source),
new JProperty("message", (string)context.Variables["errorMessage"]),
new JProperty("correlationId", context.Request.Headers.GetValueOrDefault("X-Correlation-ID", ""))
).ToString();
}</set-body>
</return-response>
</on-error>
</policies>
Module 4 – OpenAPI / Swagger Documentation {#module-4}
Generating OpenAPI Documentation
Azure API Management automatically generates OpenAPI documentation from imported APIs. You can enrich this documentation by adding descriptions, examples, and metadata.
# openapi.yaml - OpenAPI 3.0 documentation example for GloboTicket API
openapi: "3.0.3"
info:
title: "GloboTicket Products API"
description: |
REST API for managing GloboTicket products (concert tickets).
## Authentication
This API uses OAuth 2.0 with Microsoft Entra ID.
Available scopes:
- `products.read`: Read products
- `products.write`: Create, update, and delete products
## Rate Limiting
- 1000 requests per minute per subscription
- 100,000 requests per month per subscription
version: "1.0.0"
contact:
name: "GloboTicket API Support"
email: "api-support@globoticket.com"
license:
name: "Proprietary"
servers:
- url: "https://apim-globoticket-prod.azure-api.net/v1"
description: "Production"
- url: "https://apim-globoticket-staging.azure-api.net/v1"
description: "Staging"
security:
- bearerAuth: []
tags:
- name: "Products"
description: "Concert ticket management"
- name: "Categories"
description: "Product categories"
paths:
/products:
get:
tags: ["Products"]
summary: "List all products"
description: "Returns a paginated list of all available tickets."
operationId: "getProducts"
parameters:
- name: page
in: query
description: "Page number (starts at 1)"
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
description: "Number of items per page"
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: category
in: query
description: "Filter by category"
schema:
type: string
- name: search
in: query
description: "Search in name and description"
schema:
type: string
responses:
"200":
description: "Product list"
headers:
X-Total-Count:
description: "Total number of products"
schema:
type: integer
content:
application/json:
schema:
$ref: "#/components/schemas/PagedProductResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/TooManyRequests"
post:
tags: ["Products"]
summary: "Create a product"
description: "Creates a new concert ticket in the catalog."
operationId: "createProduct"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateProductRequest"
example:
name: "Coldplay - Music of the Spheres World Tour"
description: "Exceptional concert at Paris La Défense Arena"
price: 89.99
categoryId: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
availableTickets: 500
eventDate: "2025-06-15T20:00:00Z"
venue: "La Défense Arena, Paris"
responses:
"201":
description: "Product created successfully"
headers:
Location:
description: "URL of the created product"
schema:
type: string
format: uri
content:
application/json:
schema:
$ref: "#/components/schemas/ProductResponse"
"400":
$ref: "#/components/responses/BadRequest"
"409":
$ref: "#/components/responses/Conflict"
"422":
$ref: "#/components/responses/ValidationError"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: "Microsoft Entra ID JWT Token"
schemas:
ProductResponse:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
description:
type: string
price:
type: number
format: decimal
categoryName:
type: string
availableTickets:
type: integer
eventDate:
type: string
format: date-time
venue:
type: string
version:
type: integer
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
CreateProductRequest:
type: object
required: [name, price, categoryId, availableTickets, eventDate, venue]
properties:
name:
type: string
maxLength: 200
description:
type: string
maxLength: 2000
price:
type: number
minimum: 0.01
maximum: 10000
categoryId:
type: string
format: uuid
availableTickets:
type: integer
minimum: 1
eventDate:
type: string
format: date-time
venue:
type: string
maxLength: 500
PagedProductResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ProductResponse"
totalCount:
type: integer
page:
type: integer
pageSize:
type: integer
totalPages:
type: integer
hasNextPage:
type: boolean
hasPreviousPage:
type: boolean
responses:
Unauthorized:
description: "Not authenticated"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
BadRequest:
description: "Invalid request"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
Conflict:
description: "Conflict - resource already exists"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
TooManyRequests:
description: "Too many requests - rate limit exceeded"
headers:
Retry-After:
schema:
type: integer
description: "Seconds to wait before retrying"
ValidationError:
description: "Validation error"
content:
application/json:
schema:
$ref: "#/components/schemas/ValidationErrorResponse"
Exporting OpenAPI definition via APIM:
# Export OpenAPI v3 in JSON
az apim api export \
--resource-group rg-globoticket-prod \
--service-name apim-globoticket-prod \
--api-id products-api \
--export-format OpenApiJson \
--file-path ./openapi-products.json
# Export in YAML
az apim api export \
--resource-group rg-globoticket-prod \
--service-name apim-globoticket-prod \
--api-id products-api \
--export-format OpenApiYaml \
--file-path ./openapi-products.yaml
Module 5 – REST API Security {#module-5}
Azure Functions Security Levels
flowchart LR
subgraph "Level 1: Function Keys (basic)"
A1[Client] -->|?code=abc123| B1[Azure Function\nAuthLevel.Function]
end
subgraph "Level 2: APIM + Function Keys"
A2[Client] -->|Subscription Key| B2[APIM] -->|x-functions-key| C2[Azure Function\nAuthLevel.Function]
end
subgraph "Level 3: Entra ID + APIM"
A3[Client] -->|Bearer JWT| B3[APIM] -->|Validate JWT\nAdd x-functions-key| C3[Azure Function\nAuthLevel.Anonymous\nor Function]
end
style B3 fill:#0078D4,color:#fff
style C3 fill:#68217A,color:#fff
// Entra ID token validation middleware in the Function
using Microsoft.Identity.Web;
using System.Security.Claims;
namespace GloboTicket.Api;
public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
{
private readonly ITokenValidator _tokenValidator;
private readonly ILogger<AuthenticationMiddleware> _logger;
public AuthenticationMiddleware(
ITokenValidator tokenValidator,
ILogger<AuthenticationMiddleware> logger)
{
_tokenValidator = tokenValidator;
_logger = logger;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
// Don't validate health checks
if (context.FunctionDefinition.Name == "HealthCheck")
{
await next(context);
return;
}
var httpReqData = await context.GetHttpRequestDataAsync();
if (httpReqData is null)
{
await next(context);
return;
}
var authHeader = httpReqData.Headers.TryGetValues("Authorization", out var values)
? values.FirstOrDefault()
: null;
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var response = httpReqData.CreateResponse(System.Net.HttpStatusCode.Unauthorized);
await response.WriteAsJsonAsync(new { error = "Unauthorized", message = "Token required" });
context.GetInvocationResult().Value = response;
return;
}
var token = authHeader["Bearer ".Length..].Trim();
var principal = await _tokenValidator.ValidateTokenAsync(token);
if (principal is null)
{
var response = httpReqData.CreateResponse(System.Net.HttpStatusCode.Unauthorized);
await response.WriteAsJsonAsync(new { error = "InvalidToken", message = "Invalid token" });
context.GetInvocationResult().Value = response;
return;
}
// Store the principal in the context for use in functions
context.Items["User"] = principal;
_logger.LogInformation("Authenticated user: {UserId}",
principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
await next(context);
}
}
Module 6 – API Versioning and Evolution {#module-6}
Versioning Strategies
flowchart TD
A{Versioning strategy} --> B[URL Path\n/api/v1/products\n/api/v2/products]
A --> C[Query Parameter\n/api/products?api-version=1.0]
A --> D[HTTP Header\nApi-Version: 2.0]
A --> E[Content Negotiation\nAccept: application/vnd.globoticket.v2+json]
style B fill:#107C10,color:#fff
| Strategy | Advantages | Disadvantages | Recommendation |
|---|---|---|---|
| URL Path | Readable, bookmarkable, easy to route | URL pollution | ✅ Recommended for public APIs |
| Query Parameter | No URL changes | Less RESTful, easily forgotten | For backward compatibility |
| HTTP Header | Clean URL | Invisible in browsers | For internal APIs |
| Content Negotiation | MIME standard | Complex to implement | Rare cases |
URL versioning implementation in APIM:
# Create API revisions in APIM
az apim api revision create \
--resource-group rg-globoticket-prod \
--service-name apim-globoticket-prod \
--api-id products-api \
--api-revision 2 \
--api-revision-description "Added pagination to all endpoints"
# Create a v2 version of the API
az apim api release create \
--resource-group rg-globoticket-prod \
--service-name apim-globoticket-prod \
--api-id products-api \
--api-revision 2 \
--release-description "Version 2.0 - Improved pagination"
Deprecation convention:
// Signal deprecation via HTTP response headers
[Function("GetProductsV1_Deprecated")]
public async Task<HttpResponseData> GetProductsV1(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "v1/products")]
HttpRequestData req)
{
var response = await _productService.GetProductsLegacyAsync();
var httpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
// Standard deprecation headers
httpResponse.Headers.Add("Deprecation", "true");
httpResponse.Headers.Add("Sunset", "Sat, 31 Dec 2025 23:59:59 GMT");
httpResponse.Headers.Add("Link", "<https://apim.globoticket.com/v2/products>; rel=\"successor-version\"");
httpResponse.Headers.Add("Warning", "299 - \"API v1 is deprecated. Migrate to v2 before 12/31/2025.\"");
await httpResponse.WriteAsJsonAsync(response);
return httpResponse;
}
Module 7 – API Deployment and CI/CD {#module-7}
Complete CI/CD Pipeline for a REST API
# .github/workflows/deploy-api.yml
name: Deploy REST API
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
FUNCTION_APP_NAME: func-globoticket-prod
APIM_SERVICE_NAME: apim-globoticket-prod
RESOURCE_GROUP: rg-globoticket-prod
DOTNET_VERSION: '8.0.x'
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore & Build
run: |
dotnet restore
dotnet build --configuration Release
- name: Unit Tests
run: dotnet test --configuration Release --collect:"XPlat Code Coverage" --logger trx
- name: Integration Tests
run: dotnet test tests/Integration --configuration Release
env:
FunctionApp__BaseUrl: ${{ secrets.STAGING_FUNCTION_URL }}
FunctionApp__ApiKey: ${{ secrets.STAGING_API_KEY }}
- name: Publish API package
run: dotnet publish --configuration Release --output ./publish
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: api-package
path: ./publish
deploy-staging:
needs: build-and-test
runs-on: ubuntu-latest
environment: staging
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: api-package
path: ./publish
- name: Login Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy to staging slot
uses: Azure/functions-action@v1
with:
app-name: ${{ env.FUNCTION_APP_NAME }}
slot-name: staging
package: ./publish
- name: Update APIM OpenAPI spec
run: |
OPENAPI_URL="https://${{ env.FUNCTION_APP_NAME }}-staging.azurewebsites.net/api/swagger.json?code=${{ secrets.STAGING_API_KEY }}"
az apim api import \
--resource-group ${{ env.RESOURCE_GROUP }} \
--service-name ${{ env.APIM_SERVICE_NAME }} \
--api-id products-api \
--path /v1/products \
--specification-format OpenApi \
--specification-url "$OPENAPI_URL"
- name: Smoke tests
run: |
STAGING_URL="https://${{ env.APIM_SERVICE_NAME }}.azure-api.net/staging/v1"
curl -f "$STAGING_URL/health" -H "Ocp-Apim-Subscription-Key: ${{ secrets.STAGING_SUBSCRIPTION_KEY }}"
echo "Smoke test passed!"
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- name: Login Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Swap slots (staging → production)
run: |
az functionapp deployment slot swap \
--name ${{ env.FUNCTION_APP_NAME }} \
--resource-group ${{ env.RESOURCE_GROUP }} \
--slot staging \
--target-slot production
- name: Production smoke test
run: |
PROD_URL="https://${{ env.APIM_SERVICE_NAME }}.azure-api.net/v1"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PROD_URL/health" \
-H "Ocp-Apim-Subscription-Key: ${{ secrets.PROD_SUBSCRIPTION_KEY }}")
if [ "$STATUS" != "200" ]; then
echo "ERROR: Production health check failed (HTTP $STATUS)"
exit 1
fi
echo "Production deployment successful!"
Module 8 – API Monitoring and Analytics {#module-8}
APIM Analytics: Key Metrics
flowchart LR
APIM[Azure API Management] -->|Metrics| AM[Azure Monitor]
APIM -->|Logs| LA[Log Analytics]
APIM -->|Traces| AI[Application Insights]
AM --> Dashboard[Azure Dashboard]
LA --> Alerts[Automatic Alerts]
AI --> Investigation[Performance Investigation]
style APIM fill:#0078D4,color:#fff
// KQL: API call analysis by operation (Last 24h)
AzureDiagnostics
| where ResourceType == "SERVICE" and ResourceProvider == "MICROSOFT.APIMANAGEMENT"
| where TimeGenerated > ago(24h)
| where OperationName == "Microsoft.ApiManagement/service/apis/operations"
| summarize
TotalCalls = count(),
SuccessCalls = countif(responseCode_d < 400),
ErrorCalls = countif(responseCode_d >= 400),
AvgDuration = avg(DurationMs),
P95Duration = percentile(DurationMs, 95)
by apiId_s, operationId_s
| extend SuccessRate = round(SuccessCalls * 100.0 / TotalCalls, 2)
| order by TotalCalls desc
// KQL: Top clients by call volume
AzureDiagnostics
| where ResourceType == "SERVICE"
| where TimeGenerated > ago(1h)
| summarize CallCount = count() by subscriptionId_s, clientIp_s
| top 20 by CallCount desc
Module 9 – Best Practices and Patterns {#module-9}
REST API Checklist with Azure Functions
| Category | Check | Status |
|---|---|---|
| Design | URLs in lowercase and plural | ✅ |
| Design | Correct HTTP verbs (GET/POST/PUT/PATCH/DELETE) | ✅ |
| Design | Appropriate HTTP codes (201 for creates, 204 for delete) | ✅ |
| Design | Pagination on all list endpoints | ✅ |
| Design | ETag and If-Match for optimistic concurrency | ✅ |
| Security | HTTPS required | ✅ |
| Security | AuthorizationLevel.Function minimum | ✅ |
| Security | Input validation (size, format, range) | ✅ |
| Security | CORS explicitly configured | ✅ |
| Performance | Cache-Control on GET responses | ✅ |
| Performance | GZIP compression enabled | ✅ |
| Documentation | OpenAPI spec generated and up to date | ✅ |
| Monitoring | Correlation ID in all logs | ✅ |
| Errors | Consistent error messages (ApiErrorResponse) | ✅ |
| Errors | No sensitive information in errors | ✅ |
Anti-REST Patterns to Avoid
// ❌ Pattern 1: Verbs in URLs (RPC style)
// GET /api/getProducts ← BAD
// POST /api/deleteProduct ← BAD
// ✅ Correct REST
// GET /api/products
// DELETE /api/products/{id}
// ❌ Pattern 2: Always return HTTP 200 even on error
[Function("BadErrorHandling")]
public HttpResponseData BadExample(HttpRequestData req)
{
var response = req.CreateResponse(HttpStatusCode.OK); // Always 200??
response.WriteAsJsonAsync(new { success = false, error = "Not found" }); // ❌
return response;
}
// ✅ Semantically correct HTTP codes
[Function("GoodErrorHandling")]
public async Task<HttpResponseData> GoodExample(HttpRequestData req, string id)
{
var product = await _service.GetByIdAsync(id);
if (product is null)
return req.CreateResponse(HttpStatusCode.NotFound); // ✅ 404
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(product);
return response;
}
// ❌ Pattern 3: Exposing implementation details in errors
public async Task<HttpResponseData> BadErrorDetails(HttpRequestData req)
{
try { /* ... */ }
catch (SqlException ex)
{
var error = req.CreateResponse(HttpStatusCode.InternalServerError);
// ❌ Never expose stack traces or SQL details!
await error.WriteAsJsonAsync(new { error = ex.ToString(), stackTrace = ex.StackTrace });
return error;
}
}
// ✅ Generic errors with correlation
public async Task<HttpResponseData> GoodErrorDetails(HttpRequestData req)
{
var correlationId = Guid.NewGuid().ToString();
try { /* ... */ }
catch (Exception ex)
{
_logger.LogError(ex, "Internal error [{CorrelationId}]", correlationId);
var error = req.CreateResponse(HttpStatusCode.InternalServerError);
await error.WriteAsJsonAsync(new {
error = "InternalServerError",
message = "An internal error occurred.",
correlationId // Allows finding logs
});
return error;
}
}
Glossary {#glossary}
| Term | Definition |
|---|---|
| API Gateway | Centralized entry point managing routing, authentication, and policies |
| APIM | Azure API Management - Azure service for API management |
| CORS | Cross-Origin Resource Sharing - cross-domain resource sharing mechanism |
| CRUD | Create, Read, Update, Delete - basic data operations |
| ETag | Entity Tag - resource version identifier for optimistic concurrency |
| HTTP Method | HTTP verb defining the action (GET, POST, PUT, PATCH, DELETE) |
| Idempotence | The same request can be repeated without different effect (GET, PUT, DELETE) |
| If-Match | HTTP header for optimistic concurrency - checks ETag before modification |
| OAuth 2.0 | Standard authorization protocol for secured APIs |
| OpenAPI | REST API description standard (formerly Swagger) |
| Pagination | Splitting large lists into pages to optimize performance |
| Policy | APIM rule applied to requests/responses (XML) |
| Rate Limiting | Limiting the number of requests per period |
| REST | Representational State Transfer - architectural style for web APIs |
| Subscription Key | Client identification key in APIM |
| Throttling | Limiting bandwidth or request throughput |
| Versioning | Mechanism for managing API versions |
| Webhook | HTTP notification sent by a service to your API when an event occurs |
REST Principles Summary
| Principle | Description |
|---|---|
| Stateless | Each request is independent, no server-side session |
| HTTP Methods | GET (read), POST (create), PUT (replace), PATCH (update), DELETE (delete) |
| Format-agnostic | JSON, XML, Protocol Buffers, etc. |
| Loose coupling | Client and server are independent |
Azure Functions as REST Backend
Client
↓ HTTPS
https://<appname>.azurewebsites.net/api/<function-name>
↓
Azure Function (HTTP trigger)
↓
Backend (Cosmos DB, SQL, etc.)
Configuring HTTP Triggers
// GET on /api/widgets
[Function("GetWidgets")]
public HttpResponseData GetWidgets(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "widgets")]
HttpRequestData req)
{
// Return the widget list
}
// POST on /api/widgets
[Function("CreateWidget")]
public async Task<HttpResponseData> CreateWidget(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "widgets")]
HttpRequestData req)
{
var widget = await req.ReadFromJsonAsync<Widget>();
// Create the widget
}
// GET on /api/widgets/{id}
[Function("GetWidget")]
public HttpResponseData GetWidget(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "widgets/{id}")]
HttpRequestData req,
string id)
{
// Return the specific widget
}
Module 2 – Azure API Management (APIM)
What is APIM?
- Centralized gateway for APIs.
- Abstracts the backend from clients.
- Features: subscription keys, rate limiting, OAuth, request transformation.
- Developer Portal: automatic documentation for API consumers.
APIM Tiers
| Tier | Usage |
|---|---|
| Consumption | Serverless, pay-per-call, no SLA above 99.9% |
| Developer | Test/dev, no production SLA |
| Basic | Small teams, production |
| Standard | Mid-sized teams, basic multi-region |
| Premium | Enterprise, multi-region, VNet, isolation |
Importing Azure Functions into APIM
APIM → APIs → + Add API → Azure Function App
→ Select the Function App
→ APIM automatically discovers available functions
→ Configures operations (one per HTTP function)
→ API Base URL: https://my-apim.azure-api.net/widgets-api
Importing Multiple Function Apps into the Same API
APIM → APIs → Select existing API
→ + Add Operation → From Azure Function App
→ Select the second Function App
→ Operations are added to the same API
APIM Policies (Route Rewriting)
<!-- Change the URL visible to the client without changing the function route -->
<inbound>
<base />
<rewrite-uri template="/api/v1/{id}" copy-unmatched-params="true" />
</inbound>
Use case: expose /products/{id} to the client while the function responds on /api/GetProduct.
Module 3 – OpenAPI / Swagger Export
Exporting from APIM
APIM → APIs → Select the API
→ Export → OpenAPI v3 (YAML/JSON) or OpenAPI v2 (Swagger)
Export contents:
- All operations (GET, POST, etc.).
- Descriptions and tags.
- Request/response schemas.
- Authentication information.
Usage
- SDK Generation: generate client SDKs in different languages.
- Documentation: APIM Developer Portal automatically displays the API from the OpenAPI spec.
- Testing: test the API in Postman, SwaggerUI.
- Contract-first development: define the API before implementing.
Key Points
| Concept | Essential |
|---|---|
| Azure Functions as REST API | URL pattern = <appname>.azurewebsites.net/api/<function> |
| Separate functions per HTTP method | Same route, separate functions → independent scaling and maintenance |
| APIM | Centralized gateway: subscription keys, policies, developer portal |
| Import Azure Functions into APIM | Auto-discovery of functions from the Function App |
| OpenAPI export | APIM → Export → YAML/JSON for documentation and SDK generation |
| Route rewriting via APIM policies | Transform URL without modifying the function |
Module 4 – Advanced REST Design with Azure Functions
Resource-oriented Routing
The fundamental principle of a REST API is to expose resources (nouns, not verbs) accessible via stable URIs. Azure Functions implements this model via the Route parameter of the HttpTrigger.
Collection : /api/products
Element : /api/products/{id}
Sub-resource: /api/products/{id}/reviews
Filter : /api/products?category=electronics&maxPrice=500
HTTP Methods and Conventions Table
| Method | Route | Semantics | Body | Success Response |
|---|---|---|---|---|
GET | /products | List the collection | No | 200 OK + array |
GET | /products/{id} | Read an element | No | 200 OK + object |
POST | /products | Create an element | Yes (JSON) | 201 Created + Location header |
PUT | /products/{id} | Replace entirely | Yes (complete JSON) | 200 OK or 204 No Content |
PATCH | /products/{id} | Modify partially | Yes (delta JSON) | 200 OK or 204 No Content |
DELETE | /products/{id} | Delete | No | 204 No Content |
HEAD | /products/{id} | Check existence | No | 200 OK (without body) |
OPTIONS | /products | CORS preflight | No | 204 No Content + CORS headers |
Complete Example — Products CRUD
// Models/Product.cs
public record Product(
string Id,
string Name,
string Category,
decimal Price,
int StockQuantity,
DateTimeOffset CreatedAt);
public record CreateProductRequest(
string Name,
string Category,
decimal Price,
int StockQuantity);
public record UpdateProductRequest(
string? Name,
string? Category,
decimal? Price,
int? StockQuantity);
// Functions/ProductFunctions.cs
public class ProductFunctions
{
private readonly IProductService _productService;
public ProductFunctions(IProductService productService)
=> _productService = productService;
// GET /api/products?category=electronics&maxPrice=500
[Function("GetProducts")]
public async Task<HttpResponseData> GetProducts(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products")]
HttpRequestData req,
FunctionContext context)
{
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
string? category = query["category"];
decimal? maxPrice = decimal.TryParse(query["maxPrice"], out var mp) ? mp : null;
var products = await _productService.GetProductsAsync(category, maxPrice);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(products);
return response;
}
// GET /api/products/{id}
[Function("GetProductById")]
public async Task<HttpResponseData> GetProductById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products/{id}")]
HttpRequestData req,
string id)
{
var product = await _productService.GetByIdAsync(id);
if (product is null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new ProblemDetails
{
Title = "Resource not found",
Detail = $"Product '{id}' does not exist.",
Status = 404
});
return notFound;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(product);
return response;
}
// POST /api/products
[Function("CreateProduct")]
public async Task<HttpResponseData> CreateProduct(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "products")]
HttpRequestData req)
{
var request = await req.ReadFromJsonAsync<CreateProductRequest>();
var product = await _productService.CreateAsync(request!);
var response = req.CreateResponse(HttpStatusCode.Created);
response.Headers.Add("Location", $"/api/products/{product.Id}");
await response.WriteAsJsonAsync(product);
return response;
}
// PUT /api/products/{id}
[Function("ReplaceProduct")]
public async Task<HttpResponseData> ReplaceProduct(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "products/{id}")]
HttpRequestData req,
string id)
{
var request = await req.ReadFromJsonAsync<CreateProductRequest>();
var updated = await _productService.ReplaceAsync(id, request!);
if (updated is null)
return req.CreateResponse(HttpStatusCode.NotFound);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(updated);
return response;
}
// PATCH /api/products/{id}
[Function("PatchProduct")]
public async Task<HttpResponseData> PatchProduct(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "products/{id}")]
HttpRequestData req,
string id)
{
var delta = await req.ReadFromJsonAsync<UpdateProductRequest>();
var updated = await _productService.PatchAsync(id, delta!);
if (updated is null)
return req.CreateResponse(HttpStatusCode.NotFound);
return req.CreateResponse(HttpStatusCode.NoContent);
}
// DELETE /api/products/{id}
[Function("DeleteProduct")]
public async Task<HttpResponseData> DeleteProduct(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "products/{id}")]
HttpRequestData req,
string id)
{
var deleted = await _productService.DeleteAsync(id);
return req.CreateResponse(deleted ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
}
}
REST Request Processing Flow
sequenceDiagram
participant C as Client
participant APIM as API Management
participant F as Azure Function
participant DB as Cosmos DB
C->>APIM: GET /products/abc-123
APIM->>APIM: Validate subscription-key / JWT
APIM->>APIM: Apply policies (rate-limit, cors)
APIM->>F: GET /api/products/abc-123
F->>DB: ReadItemAsync("abc-123")
DB-->>F: Product document
F-->>APIM: 200 OK + JSON
APIM-->>C: 200 OK + JSON
Module 5 – OpenAPI (Swagger) with Azure Functions
Why Document with OpenAPI?
OpenAPI 3.x is the industry standard for describing API contracts. With Azure Functions, two approaches exist:
- Export from APIM (seen in module 3) — recommended for existing APIs.
- azure-functions-openapi-extension — generates the spec directly from C# attributes.
Installing the Extension
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.OpenApi
// Program.cs — registration
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults(worker =>
{
worker.UseNewtonsoftJson();
})
.ConfigureServices(services =>
{
services.AddSingleton<IOpenApiConfigurationOptions>(_ =>
{
var options = new OpenApiConfigurationOptions
{
Info = new OpenApiInfo
{
Version = DefaultOpenApiSpecVersion.V3,
Title = "Products API",
Description = "Product management API with Azure Functions",
TermsOfService = new Uri("https://example.com/terms"),
Contact = new OpenApiContact
{
Name = "API Team",
Email = "api@example.com"
}
},
Servers = DefaultOpenApiSpecVersion.V3
.GetServers(new[] { "https://my-func.azurewebsites.net/api" })
};
return options;
});
})
.Build();
OpenAPI Decorators on Functions
[Function("GetProductById")]
[OpenApiOperation(operationId: "GetProductById", tags: new[] { "Products" },
Summary = "Retrieve a product by its identifier",
Description = "Returns the full details of a product. Returns 404 if not found.")]
[OpenApiParameter(name: "id", In = ParameterLocation.Path, Required = true,
Type = typeof(string), Description = "Unique product identifier (GUID)")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json",
bodyType: typeof(Product), Description = "Product found")]
[OpenApiResponseWithoutBody(statusCode: HttpStatusCode.NotFound,
Description = "Product not found")]
public async Task<HttpResponseData> GetProductById(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products/{id}")]
HttpRequestData req, string id)
{ /* ... */ }
[Function("CreateProduct")]
[OpenApiOperation(operationId: "CreateProduct", tags: new[] { "Products" },
Summary = "Create a new product")]
[OpenApiRequestBody(contentType: "application/json", bodyType: typeof(CreateProductRequest),
Required = true, Description = "Product data to create")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.Created, contentType: "application/json",
bodyType: typeof(Product), Description = "Product created")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.BadRequest, contentType: "application/json",
bodyType: typeof(ValidationProblemDetails), Description = "Invalid data")]
public async Task<HttpResponseData> CreateProduct(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "products")]
HttpRequestData req)
{ /* ... */ }
Auto-generated Swagger UI Endpoints
| Endpoint | Description |
|---|---|
GET /api/swagger/ui | Interactive Swagger UI |
GET /api/swagger.json | OpenAPI 3.0 spec in JSON |
GET /api/swagger.yaml | OpenAPI 3.0 spec in YAML |
Example Generated OpenAPI Spec
openapi: "3.0.1"
info:
title: Products API
description: Product management API with Azure Functions
version: "1.0"
paths:
/products/{id}:
get:
tags:
- Products
summary: Retrieve a product by its identifier
operationId: GetProductById
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: Product found
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
"404":
description: Product not found
Module 6 – Validation and Model Binding
Validation Pipeline
flowchart TD
A[Incoming HTTP Request] --> B{JSON body present?}
B -- No --> C[400 Bad Request\nBody required]
B -- Yes --> D[JSON Deserialization]
D --> E{Deserialization OK?}
E -- No --> F[400 Bad Request\nMalformed JSON]
E -- Yes --> G[Business rule validation]
G --> H{Valid?}
H -- No --> I[400 Bad Request\nValidation errors]
H -- Yes --> J[Business processing]
J --> K[201 Created / 200 OK]
Installing FluentValidation
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
// Validators/CreateProductValidator.cs
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(200).WithMessage("Name cannot exceed 200 characters.");
RuleFor(x => x.Category)
.NotEmpty().WithMessage("Category is required.")
.Must(c => new[] { "electronics", "clothing", "food" }.Contains(c))
.WithMessage("Invalid category. Accepted values: electronics, clothing, food.");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0.")
.LessThanOrEqualTo(99999.99m).WithMessage("Price is too high.");
RuleFor(x => x.StockQuantity)
.GreaterThanOrEqualTo(0).WithMessage("Stock quantity cannot be negative.");
}
}
// Extensions/ValidationExtensions.cs
public static class ValidationExtensions
{
public static async Task<HttpResponseData?> ValidateAndRespondAsync<T>(
this HttpRequestData req,
T model,
IValidator<T> validator)
{
var result = await validator.ValidateAsync(model);
if (result.IsValid)
return null; // No error → continue
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
var problemDetails = new ValidationProblemDetails(errors)
{
Title = "One or more validation errors occurred.",
Status = 400,
Type = "https://tools.ietf.org/html/rfc7807"
};
var response = req.CreateResponse(HttpStatusCode.BadRequest);
response.Headers.Add("Content-Type", "application/problem+json");
await response.WriteAsJsonAsync(problemDetails);
return response;
}
}
// Usage in CreateProduct function
[Function("CreateProduct")]
public async Task<HttpResponseData> CreateProduct(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "products")]
HttpRequestData req)
{
CreateProductRequest? request;
try
{
request = await req.ReadFromJsonAsync<CreateProductRequest>();
if (request is null)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new ProblemDetails
{
Title = "Request body missing or invalid.",
Status = 400
});
return badReq;
}
}
catch (JsonException ex)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new ProblemDetails
{
Title = "Malformed JSON.",
Detail = ex.Message,
Status = 400
});
return badReq;
}
// FluentValidation
var validationError = await req.ValidateAndRespondAsync(request, _validator);
if (validationError is not null)
return validationError;
var product = await _productService.CreateAsync(request);
var response = req.CreateResponse(HttpStatusCode.Created);
response.Headers.Add("Location", $"/api/products/{product.Id}");
await response.WriteAsJsonAsync(product);
return response;
}
Problem Details Format (RFC 7807)
{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required."],
"Price": ["Price must be greater than 0."]
},
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
Module 7 – API Versioning
Versioning Strategies
flowchart LR
subgraph "Strategies"
A[URL Path\n/api/v1/products]
B[Query String\n/api/products?api-version=1.0]
C[Header\napi-version: 1.0]
D[Media Type\nAccept: application/vnd.api+json;version=1]
end
A -->|Recommended\nMaximum visibility| E[✓ Azure Functions]
B -->|Simple\ncaching difficult| E
C -->|Clean\nless visible| E
D -->|Complex\nrarely used| E
URL Path Versioning
// v1 — returns the simplified model
[Function("GetProductsV1")]
public async Task<HttpResponseData> GetProductsV1(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v1/products")]
HttpRequestData req)
{
var products = await _productService.GetProductsV1Async();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(products);
return response;
}
// v2 — returns the enriched model with pagination
[Function("GetProductsV2")]
public async Task<HttpResponseData> GetProductsV2(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v2/products")]
HttpRequestData req)
{
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
int page = int.TryParse(query["page"], out var p) ? p : 1;
int pageSize = int.TryParse(query["pageSize"], out var ps) ? ps : 20;
var pagedResult = await _productService.GetProductsPagedAsync(page, pageSize);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(pagedResult);
return response;
}
Header Versioning with APIM
<!-- APIM Policy: route based on api-version header -->
<inbound>
<base />
<choose>
<when condition="@(context.Request.Headers.GetValueOrDefault("api-version","1") == "2")">
<set-backend-service base-url="https://my-func-v2.azurewebsites.net/api" />
</when>
<otherwise>
<set-backend-service base-url="https://my-func-v1.azurewebsites.net/api" />
</otherwise>
</choose>
</inbound>
Deprecation Strategy
// Signal a deprecated version via response headers
[Function("GetProductsV1")]
public async Task<HttpResponseData> GetProductsV1(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v1/products")]
HttpRequestData req)
{
var response = req.CreateResponse(HttpStatusCode.OK);
// RFC 8594 — Sunset header
response.Headers.Add("Sunset", "Sat, 31 Dec 2025 23:59:59 GMT");
response.Headers.Add("Deprecation", "true");
response.Headers.Add("Link",
"</api/v2/products>; rel=\"successor-version\"");
var products = await _productService.GetProductsV1Async();
await response.WriteAsJsonAsync(products);
return response;
}
| Header | Meaning |
|---|---|
Sunset | End-of-life date for the version (RFC 8594) |
Deprecation | Indicates the version is deprecated |
Link: rel="successor-version" | Points to the replacement version |
Module 8 – HATEOAS and Hypermedia
HATEOAS Principle
HATEOAS (Hypermedia As The Engine Of Application State) enriches REST responses with navigable links, allowing clients to discover available actions without external documentation.
{
"id": "prod-123",
"name": "Laptop Pro",
"price": 1299.99,
"_links": {
"self": { "href": "/api/v1/products/prod-123", "method": "GET" },
"update": { "href": "/api/v1/products/prod-123", "method": "PUT" },
"patch": { "href": "/api/v1/products/prod-123", "method": "PATCH" },
"delete": { "href": "/api/v1/products/prod-123", "method": "DELETE" },
"reviews": { "href": "/api/v1/products/prod-123/reviews", "method": "GET" },
"collection": { "href": "/api/v1/products", "method": "GET" }
}
}
HATEOAS Models
// Models/HateoasModels.cs
public record Link(string Href, string Method, string? Rel = null);
public record HateoasResponse<T>(T Data, Dictionary<string, Link> Links);
public record PagedHateoasResponse<T>(
IEnumerable<T> Items,
int TotalCount,
int Page,
int PageSize,
Dictionary<string, Link> Links);
// Helpers/LinkBuilder.cs
public class LinkBuilder
{
private readonly string _baseUrl;
public LinkBuilder(string baseUrl) => _baseUrl = baseUrl.TrimEnd('/');
public Dictionary<string, Link> BuildProductLinks(string productId) => new()
{
["self"] = new Link($"{_baseUrl}/products/{productId}", "GET"),
["update"] = new Link($"{_baseUrl}/products/{productId}", "PUT"),
["patch"] = new Link($"{_baseUrl}/products/{productId}", "PATCH"),
["delete"] = new Link($"{_baseUrl}/products/{productId}", "DELETE"),
["reviews"] = new Link($"{_baseUrl}/products/{productId}/reviews", "GET"),
["collection"] = new Link($"{_baseUrl}/products", "GET")
};
public Dictionary<string, Link> BuildCollectionLinks(
int page, int pageSize, int totalCount, string route)
{
var links = new Dictionary<string, Link>
{
["self"] = new Link($"{_baseUrl}/{route}?page={page}&pageSize={pageSize}", "GET")
};
if (page > 1)
links["prev"] = new Link(
$"{_baseUrl}/{route}?page={page - 1}&pageSize={pageSize}", "GET");
int totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
if (page < totalPages)
links["next"] = new Link(
$"{_baseUrl}/{route}?page={page + 1}&pageSize={pageSize}", "GET");
links["first"] = new Link($"{_baseUrl}/{route}?page=1&pageSize={pageSize}", "GET");
links["last"] = new Link($"{_baseUrl}/{route}?page={totalPages}&pageSize={pageSize}", "GET");
return links;
}
}
Collection Response with HATEOAS Links
{
"items": [
{
"id": "prod-001",
"name": "Laptop Pro",
"price": 1299.99,
"_links": {
"self": { "href": "/api/v1/products/prod-001", "method": "GET" }
}
}
],
"totalCount": 142,
"page": 2,
"pageSize": 20,
"_links": {
"self": { "href": "/api/v1/products?page=2&pageSize=20", "method": "GET" },
"prev": { "href": "/api/v1/products?page=1&pageSize=20", "method": "GET" },
"next": { "href": "/api/v1/products?page=3&pageSize=20", "method": "GET" },
"first": { "href": "/api/v1/products?page=1&pageSize=20", "method": "GET" },
"last": { "href": "/api/v1/products?page=8&pageSize=20", "method": "GET" }
}
}
Module 9 – In-Depth API Management Integration
Architecture with APIM
flowchart TD
subgraph "Clients"
MobileApp[Mobile Application]
WebApp[Web Application]
ThirdParty[Third-party API]
end
subgraph "Azure API Management"
Gateway[APIM Gateway\nhttps://my-apim.azure-api.net]
DevPortal[Developer Portal]
Analytics[Analytics & Logs]
end
subgraph "Azure Functions Backend"
FuncV1[Function App V1\nProducts]
FuncV2[Function App V2\nOrders]
FuncAuth[Function App\nAuthentication]
end
MobileApp --> Gateway
WebApp --> Gateway
ThirdParty --> Gateway
Gateway --> FuncV1
Gateway --> FuncV2
Gateway --> FuncAuth
Gateway --> Analytics
DevPortal --> Gateway
Essential APIM Policies
Rate Limiting
<inbound>
<base />
<!-- 100 calls per minute per subscription key -->
<rate-limit-by-key calls="100" renewal-period="60"
counter-key="@(context.Subscription.Id)" />
<!-- 10,000 calls per day per IP -->
<quota-by-key calls="10000" renewal-period="86400"
counter-key="@(context.Request.IpAddress)" />
</inbound>
CORS
<inbound>
<base />
<cors allow-credentials="true">
<allowed-origins>
<origin>https://my-webapp.com</origin>
<origin>https://localhost:3000</origin>
</allowed-origins>
<allowed-methods preflight-result-max-age="300">
<method>GET</method>
<method>POST</method>
<method>PUT</method>
<method>PATCH</method>
<method>DELETE</method>
</allowed-methods>
<allowed-headers>
<header>Content-Type</header>
<header>Authorization</header>
<header>api-version</header>
</allowed-headers>
</cors>
</inbound>
JWT Validation
<inbound>
<base />
<validate-jwt header-name="Authorization" failed-validation-httpcode="401"
failed-validation-error-message="Invalid or expired JWT token.">
<openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
<required-claims>
<claim name="aud" match="all">
<value>api://my-api-app-id</value>
</claim>
<claim name="roles" match="any">
<value>Products.Read</value>
<value>Products.Write</value>
</claim>
</required-claims>
</validate-jwt>
</inbound>
Set Backend Service (backend rewriting)
<inbound>
<base />
<set-backend-service
base-url="https://my-function-app.azurewebsites.net/api" />
<!-- Add function key as header -->
<set-header name="x-functions-key" exists-action="override">
<value>{{function-app-key}}</value>
</set-header>
</inbound>
Publishing an APIM Product
sequenceDiagram
participant Admin as Administrator
participant APIM as API Management
participant Dev as Developer
Admin->>APIM: Create Product "Premium API"
Admin->>APIM: Associate APIs with the product
Admin->>APIM: Configure quotas & product policies
Admin->>APIM: Publish the product
Dev->>APIM: Register on Developer Portal
Dev->>APIM: Request subscription to product
Admin->>APIM: Approve subscription
APIM-->>Dev: Provide subscription-key
Dev->>APIM: Use the API with subscription-key
Module 10 – Pagination, Filtering, Sorting
OData-inspired Query Parameters
| Parameter | Type | Example | Description |
|---|---|---|---|
$top | int | $top=20 | Number of items to return |
$skip | int | $skip=40 | Number of items to skip (offset) |
$filter | string | $filter=category eq 'electronics' | OData filter |
$orderby | string | $orderby=price desc | Sort |
$select | string | $select=id,name,price | Field projection |
$count | bool | $count=true | Include total count |
continuationToken | string | (opaque token) | Cosmos DB pagination |
Complete Implementation
// Models/QueryParameters.cs
public class ProductQueryParameters
{
private const int MaxPageSize = 100;
private int _pageSize = 20;
public int Page { get; set; } = 1;
public int PageSize
{
get => _pageSize;
set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
}
public string? Category { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
public string? OrderBy { get; set; } = "name";
public string OrderDirection { get; set; } = "asc"; // asc | desc
public string? Search { get; set; }
}
// Function with complete pagination
[Function("GetProductsPaged")]
public async Task<HttpResponseData> GetProductsPaged(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v2/products")]
HttpRequestData req)
{
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var parameters = new ProductQueryParameters
{
Page = int.TryParse(qs["page"], out var p) ? p : 1,
PageSize = int.TryParse(qs["pageSize"], out var ps) ? ps : 20,
Category = qs["category"],
MinPrice = decimal.TryParse(qs["minPrice"], out var min) ? min : null,
MaxPrice = decimal.TryParse(qs["maxPrice"], out var max) ? max : null,
OrderBy = qs["orderBy"] ?? "name",
OrderDirection = qs["orderDirection"] ?? "asc",
Search = qs["search"]
};
var (items, totalCount) = await _productService.GetPagedAsync(parameters);
var result = new
{
items,
pagination = new
{
page = parameters.Page,
pageSize = parameters.PageSize,
totalCount,
totalPages = (int)Math.Ceiling((double)totalCount / parameters.PageSize),
hasNextPage = parameters.Page * parameters.PageSize < totalCount,
hasPrevPage = parameters.Page > 1
}
};
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(result);
return response;
}
Continuation Token Pagination (Cosmos DB)
// Cosmos DB returns an opaque token for the next page
[Function("GetProductsByContinuation")]
public async Task<HttpResponseData> GetProductsByContinuation(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v3/products")]
HttpRequestData req)
{
var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
int pageSize = int.TryParse(qs["pageSize"], out var ps) ? ps : 20;
string? continuationToken = qs["continuationToken"];
// Token is Base64 URL encoded to be safe in query strings
if (continuationToken is not null)
continuationToken = System.Text.Encoding.UTF8.GetString(
Convert.FromBase64String(continuationToken));
var (items, nextToken) = await _cosmosService.GetWithContinuationAsync(
pageSize, continuationToken);
var result = new
{
items,
nextContinuationToken = nextToken is null ? null :
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(nextToken)),
hasMore = nextToken is not null
};
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(result);
return response;
}
Module 11 – Error Handling
HTTP Codes and Domain Exceptions
flowchart TD
EX[Exception thrown] --> T{Type?}
T -->|NotFoundException| A[404 Not Found]
T -->|ValidationException| B[400 Bad Request]
T -->|UnauthorizedException| C[401 Unauthorized]
T -->|ForbiddenException| D[403 Forbidden]
T -->|ConflictException| E[409 Conflict]
T -->|RateLimitException| F[429 Too Many Requests]
T -->|ServiceUnavailableException| G[503 Service Unavailable]
T -->|Generic Exception| H[500 Internal Server Error]
Domain Exceptions
// Exceptions/DomainExceptions.cs
public abstract class DomainException : Exception
{
public abstract HttpStatusCode StatusCode { get; }
public abstract string ErrorType { get; }
protected DomainException(string message) : base(message) { }
}
public class NotFoundException : DomainException
{
public override HttpStatusCode StatusCode => HttpStatusCode.NotFound;
public override string ErrorType => "https://tools.ietf.org/html/rfc7231#section-6.5.4";
public NotFoundException(string resource, string id)
: base($"Resource '{resource}' with identifier '{id}' not found.") { }
}
public class ConflictException : DomainException
{
public override HttpStatusCode StatusCode => HttpStatusCode.Conflict;
public override string ErrorType => "https://tools.ietf.org/html/rfc7231#section-6.5.8";
public ConflictException(string message) : base(message) { }
}
public class BusinessRuleException : DomainException
{
public override HttpStatusCode StatusCode => HttpStatusCode.UnprocessableEntity;
public override string ErrorType => "https://tools.ietf.org/html/rfc4918#section-11.2";
public BusinessRuleException(string message) : base(message) { }
}
Global Error Handling Middleware
// Middleware/ExceptionHandlingMiddleware.cs
public class ExceptionHandlingMiddleware : IFunctionsWorkerMiddleware
{
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(ILogger<ExceptionHandlingMiddleware> logger)
=> _logger = logger;
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
try
{
await next(context);
}
catch (DomainException domainEx)
{
_logger.LogWarning(domainEx,
"Domain exception in {FunctionName}: {Message}",
context.FunctionDefinition.Name, domainEx.Message);
await SetErrorResponseAsync(context, domainEx.StatusCode, new ProblemDetails
{
Type = domainEx.ErrorType,
Title = GetTitleForStatus(domainEx.StatusCode),
Detail = domainEx.Message,
Status = (int)domainEx.StatusCode
});
}
catch (Exception ex)
{
_logger.LogError(ex,
"Unexpected error in {FunctionName}",
context.FunctionDefinition.Name);
await SetErrorResponseAsync(context, HttpStatusCode.InternalServerError, new ProblemDetails
{
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
Title = "An internal error occurred.",
Detail = "Please contact support if the problem persists.",
Status = 500
});
}
}
private static async Task SetErrorResponseAsync(
FunctionContext context,
HttpStatusCode statusCode,
ProblemDetails problemDetails)
{
var request = await context.GetHttpRequestDataAsync();
if (request is null) return;
var response = request.CreateResponse(statusCode);
response.Headers.Add("Content-Type", "application/problem+json; charset=utf-8");
await response.WriteAsJsonAsync(problemDetails);
context.GetInvocationResult().Value = response;
}
private static string GetTitleForStatus(HttpStatusCode code) => code switch
{
HttpStatusCode.NotFound => "Resource not found.",
HttpStatusCode.Conflict => "Resource conflict.",
HttpStatusCode.UnprocessableEntity => "Business rule violated.",
HttpStatusCode.Forbidden => "Access denied.",
HttpStatusCode.Unauthorized => "Authentication required.",
_ => "An error occurred."
};
}
// Registration in Program.cs
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults(worker =>
{
worker.UseMiddleware<ExceptionHandlingMiddleware>();
})
.Build();
Structured Error Response (RFC 7807)
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Resource not found.",
"status": 404,
"detail": "Resource 'Product' with identifier 'prod-999' not found.",
"instance": "/api/v1/products/prod-999",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
Module 12 – REST API Testing
Testing Strategy
flowchart LR
subgraph "Testing Pyramid"
U[Unit Tests\nBusiness logic / Validators\nFast, isolated]
I[Integration Tests\nFunctions + real dependencies\nTestServer / HttpClient]
E[End-to-End Tests\nAgainst real deployment\nPostman / Newman]
end
U --> I --> E
HTTP Files (REST Client VS Code)
Create a requests/products.http file in your project:
### Variables
@baseUrl = http://localhost:7071/api
@productId = prod-123
### GET all products
GET {{baseUrl}}/products
Content-Type: application/json
### GET product by ID
GET {{baseUrl}}/products/{{productId}}
### POST — Create a product
POST {{baseUrl}}/products
Content-Type: application/json
{
"name": "Laptop Pro 15",
"category": "electronics",
"price": 1299.99,
"stockQuantity": 50
}
### PUT — Replace a product
PUT {{baseUrl}}/products/{{productId}}
Content-Type: application/json
{
"name": "Laptop Pro 15 Updated",
"category": "electronics",
"price": 1199.99,
"stockQuantity": 45
}
### PATCH — Modify price only
PATCH {{baseUrl}}/products/{{productId}}
Content-Type: application/json
{
"price": 999.99
}
### DELETE — Remove a product
DELETE {{baseUrl}}/products/{{productId}}
Integration Tests with xUnit and TestServer
dotnet add package Microsoft.AspNetCore.TestHost
dotnet add package Microsoft.Azure.Functions.Worker.Testing
// Tests/ProductIntegrationTests.cs
public class ProductIntegrationTests : IClassFixture<FunctionsApplicationFactory>
{
private readonly HttpClient _client;
public ProductIntegrationTests(FunctionsApplicationFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsOkWithCollection()
{
// Act
var response = await _client.GetAsync("/api/products");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadFromJsonAsync<JsonElement>();
content.GetProperty("items").GetArrayLength().Should().BeGreaterThan(0);
}
[Fact]
public async Task CreateProduct_WithValidData_ReturnsCreated()
{
// Arrange
var request = new CreateProductRequest(
Name: "Test Product",
Category: "electronics",
Price: 99.99m,
StockQuantity: 10);
// Act
var response = await _client.PostAsJsonAsync("/api/products", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var product = await response.Content.ReadFromJsonAsync<Product>();
product!.Name.Should().Be("Test Product");
product.Id.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task CreateProduct_WithInvalidData_ReturnsBadRequest()
{
// Arrange — empty name and negative price
var request = new CreateProductRequest(
Name: "",
Category: "unknown",
Price: -10m,
StockQuantity: 0);
// Act
var response = await _client.PostAsJsonAsync("/api/products", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("Name");
problem.Errors.Should().ContainKey("Price");
}
[Fact]
public async Task GetProductById_NotFound_Returns404WithProblemDetails()
{
// Act
var response = await _client.GetAsync("/api/products/nonexistent-id");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(404);
problem.Title.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData("GET", "/api/products", HttpStatusCode.OK)]
[InlineData("GET", "/api/products/xxx", HttpStatusCode.NotFound)]
[InlineData("DELETE", "/api/products/xxx", HttpStatusCode.NotFound)]
public async Task ApiRoutes_ReturnExpectedStatusCodes(
string method, string url, HttpStatusCode expected)
{
var request = new HttpRequestMessage(new HttpMethod(method), url);
var response = await _client.SendAsync(request);
response.StatusCode.Should().Be(expected);
}
}
Module 13 – Performance and Caching
HTTP Caching Strategies
| Header | Purpose | Example |
|---|---|---|
Cache-Control | Cache directives for proxies and clients | public, max-age=300 |
ETag | Resource version identifier | "abc123def456" |
Last-Modified | Last modification date | Wed, 21 Oct 2024 07:28:00 GMT |
Vary | Indicates headers that vary the response | Accept-Encoding, Accept-Language |
Conditional GET with ETag
[Function("GetProductWithCaching")]
public async Task<HttpResponseData> GetProductWithCaching(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products/{id}")]
HttpRequestData req,
string id)
{
var product = await _productService.GetByIdAsync(id);
if (product is null)
return req.CreateResponse(HttpStatusCode.NotFound);
// Calculate ETag from content hash
var json = JsonSerializer.Serialize(product);
var etag = $"\"{Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(json)))[..16]}\"";
// Check If-None-Match (client already has current version)
var ifNoneMatch = req.Headers.TryGetValues("If-None-Match", out var values)
? values.FirstOrDefault() : null;
if (ifNoneMatch == etag)
return req.CreateResponse(HttpStatusCode.NotModified);
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("ETag", etag);
response.Headers.Add("Cache-Control", "public, max-age=300");
response.Headers.Add("Last-Modified",
product.CreatedAt.ToString("R")); // RFC 1123 format
await response.WriteAsJsonAsync(product);
return response;
}
Response Compression
// Program.cs — enable compression
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
new[] { "application/json" });
});
services.Configure<BrotliCompressionProviderOptions>(options =>
options.Level = System.IO.Compression.CompressionLevel.Fastest);
// Manual compression for Azure Functions Worker
[Function("GetLargeCollection")]
public async Task<HttpResponseData> GetLargeCollection(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products/export")]
HttpRequestData req)
{
var products = await _productService.GetAllAsync();
var json = JsonSerializer.SerializeToUtf8Bytes(products);
var acceptEncoding = req.Headers.TryGetValues("Accept-Encoding", out var enc)
? enc.FirstOrDefault() ?? string.Empty : string.Empty;
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json");
if (acceptEncoding.Contains("br"))
{
response.Headers.Add("Content-Encoding", "br");
using var ms = new MemoryStream();
using (var brotli = new System.IO.Compression.BrotliStream(
ms, System.IO.Compression.CompressionMode.Compress))
await brotli.WriteAsync(json);
await response.Body.WriteAsync(ms.ToArray());
}
else if (acceptEncoding.Contains("gzip"))
{
response.Headers.Add("Content-Encoding", "gzip");
using var ms = new MemoryStream();
using (var gzip = new System.IO.Compression.GZipStream(
ms, System.IO.Compression.CompressionMode.Compress))
await gzip.WriteAsync(json);
await response.Body.WriteAsync(ms.ToArray());
}
else
{
await response.Body.WriteAsync(json);
}
return response;
}
Async Response Streaming
// Streaming large dataset with IAsyncEnumerable
[Function("StreamProducts")]
public async Task<HttpResponseData> StreamProducts(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products/stream")]
HttpRequestData req)
{
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/x-ndjson"); // Newline-delimited JSON
response.Headers.Add("Transfer-Encoding", "chunked");
response.Headers.Add("X-Accel-Buffering", "no"); // Disable nginx buffering
await using var writer = new StreamWriter(response.Body, leaveOpen: true);
await foreach (var product in _productService.StreamAllAsync())
{
var line = JsonSerializer.Serialize(product);
await writer.WriteLineAsync(line);
await writer.FlushAsync(); // Send each line immediately
}
return response;
}
Azure CDN Integration
flowchart LR
C[Client] --> CDN[Azure CDN\nAkamai / Verizon]
CDN -->|Cache miss| APIM[API Management]
APIM --> F[Azure Function]
CDN -->|Cache hit\n< 1ms| C
subgraph "Cache Headers"
H1["Cache-Control: public, max-age=3600"]
H2["ETag: 'abc123'"]
H3["Vary: Accept-Encoding"]
end
F --> H1
F --> H2
F --> H3
Module 14 – Review Questions
Questions
1. What is the fundamental difference between using PUT and PATCH in a REST API, and when would you choose one over the other?
2. In Azure Functions, you have two functions with Route = "products/{id}". One accepts "get" and the other "put". Is this a valid configuration? Explain why.
3. What does an OpenAPI export generated from APIM contain, and which tool(s) can benefit from it?
4. You receive a GET /api/products/xyz request and the product doesn’t exist. What HTTP code do you return and how do you structure the error response per RFC 7807?
5. Explain the role of the ETag header in HTTP caching. How does If-None-Match allow bandwidth savings?
6. What is the difference between offset pagination (page/pageSize) and continuation token pagination? In what context is each approach recommended?
7. You want to expose a v1 and v2 of your API through APIM, each pointing to a distinct Function App. How do you configure this with an APIM policy?
8. What are the advantages of using FluentValidation over manual validation in Azure Functions? Give two examples of validation rules expressible with FluentValidation.
9. What is HATEOAS and how does it enrich a REST API’s responses? Give a concrete JSON example with self, next, and prev links.
10. Describe the testing pyramid for an Azure Functions API. Which types of tests cover the Function itself (route, status codes, response body) and which C# tool would you use?
Answers
1. PUT completely replaces the resource (client sends all fields). PATCH partially modifies (only the provided fields). Use PATCH when you only want to modify one attribute (e.g., update only the price) to avoid sending unnecessary data and accidentally overwriting unprovided fields.
2. Yes, it’s valid. Azure Functions allows multiple functions on the same route as long as the route + HTTP method combination is unique. The route products/{id} with GET and the same route with PUT are two distinct entries.
3. The export contains: all operations (methods, routes, parameters), request/response schemas, descriptions and tags, authentication info. It can be used for: client SDK generation, Swagger UI documentation, Postman testing, and team contracts (contract-first).
4. Return 404 Not Found with a Content-Type: application/problem+json body structured as: { "type": "...", "title": "Resource not found.", "status": 404, "detail": "Product 'xyz' does not exist." }.
5. The ETag is an opaque identifier of the current version of a resource (often a hash). The client stores this ETag and sends it back in If-None-Match on the next request. If the resource hasn’t changed, the server responds 304 Not Modified without a body, saving the bandwidth of the payload.
6. Offset pagination is simple but inefficient for large datasets (the DB must count/skip N rows). Continuation token pagination (Cosmos DB) uses an opaque pointer returned by the backend — more efficient and consistent for changing collections. Recommendation: offset for SQL/small datasets, continuation token for Cosmos DB/large volumes.
7. In the <inbound> policy, use <choose> with a condition on the api-version header (or URL path) to call <set-backend-service base-url="..."> pointing to one or the other Function App depending on the requested version.
8. FluentValidation offers: declarative and reusable rules, custom error messages, composable rules (conditions, inter-field dependencies), and better testability (validate the validator independently). Examples: RuleFor(x => x.Name).NotEmpty().MaximumLength(200) and RuleFor(x => x.Price).GreaterThan(0).LessThanOrEqualTo(99999).
9. HATEOAS enriches each response with navigable links to available actions, allowing the client to discover the API without prior URL knowledge. Example: { "id": "p1", "name": "Laptop", "_links": { "self": { "href": "/api/products/p1", "method": "GET" }, "next": { "href": "/api/products?page=3", "method": "GET" }, "prev": { "href": "/api/products?page=1", "method": "GET" } } }.
10. The pyramid: (bottom) unit tests for business logic and validators (xUnit + Moq, fast, isolated); (middle) integration tests against Functions with FunctionsApplicationFactory + HttpClient to validate routes, status codes, and response bodies; (top) E2E tests with Postman/Newman or HTTP files against the real deployment. Integration tests with xUnit + FluentAssertions best cover the Function layer itself.
REST Best Practices with Azure Functions
- Separate functions by HTTP method: GetWidgets (GET), CreateWidget (POST) → same route, separate functions.
- Benefits: independent scaling per method, separate maintenance, clear responsibility.
- Route params:
{id}in the route template.
Search Terms
deploying · azure · functions · rest · apis · serverless · microsoft · api · apim · openapi · http · versioning · response · backend · hateoas · management · swagger · crud · error · importing · integration · policies · principles · strategies