Intermediate

Deploying Azure Functions as REST APIs

Design REST APIs on Azure Functions and publish them through API Management with OpenAPI and security.

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

  1. REST Principles and Azure Functions as API Backend
  2. Designing a Complete REST API
  3. Azure API Management (APIM)
  4. OpenAPI / Swagger Documentation
  5. REST API Security
  6. API Versioning and Evolution
  7. API Deployment and CI/CD
  8. API Monitoring and Analytics
  9. Best Practices and Patterns
  10. 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:

ConstraintDescriptionImpact
StatelessEach request contains all necessary information; no server-side sessionEasier horizontal scaling
Client-ServerClear separation between client (UI) and server (data)Development independence
CacheableResponses indicate whether they can be cachedPerformance and reduced load
Uniform InterfaceStandardized interface (resources, HTTP verbs, representation)Universal interoperability
Layered SystemClient doesn’t know if it’s talking directly to the server (proxies, APIM)Architectural flexibility
Code on DemandOptional: transfer of executable code to the clientExtensibility

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:

ConventionExampleBest 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:

CodeMeaningUsage
200 OKSuccessGET, PUT, PATCH (existing resource)
201 CreatedResource createdSuccessful POST
204 No ContentSuccess without bodyDELETE, PUT without return
400 Bad RequestInvalid requestValidation failed
401 UnauthorizedNot authenticatedMissing or expired token
403 ForbiddenNot authorizedAuthenticated but no permission
404 Not FoundResource absentNon-existent resource
409 ConflictConflictResource already exists
422 Unprocessable EntityInvalid business logicBusiness constraints not met
429 Too Many RequestsRate limit exceededAPIM throttling
500 Internal Server ErrorServer errorUnhandled exception
503 Service UnavailableService unavailableMaintenance, 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

TiervCoresUnitsSLAFeaturesUse Case
Developer11Non-productionAll except SLADev/Test only
Basic11-499.95%StandardSmall APIs
Standard21-499.95%+ User-defined gatewaysMedium APIs
Premium41-1299.99%+ Multi-region, VNetEnterprise
ConsumptionServerlessPay/call99.95%LimitedPrototyping
Basic v2-1+99.95%EnhancedLight load
Standard v2-1+99.99%+ VNet InjectionMedium 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
StrategyAdvantagesDisadvantagesRecommendation
URL PathReadable, bookmarkable, easy to routeURL pollutionRecommended for public APIs
Query ParameterNo URL changesLess RESTful, easily forgottenFor backward compatibility
HTTP HeaderClean URLInvisible in browsersFor internal APIs
Content NegotiationMIME standardComplex to implementRare 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

CategoryCheckStatus
DesignURLs in lowercase and plural
DesignCorrect HTTP verbs (GET/POST/PUT/PATCH/DELETE)
DesignAppropriate HTTP codes (201 for creates, 204 for delete)
DesignPagination on all list endpoints
DesignETag and If-Match for optimistic concurrency
SecurityHTTPS required
SecurityAuthorizationLevel.Function minimum
SecurityInput validation (size, format, range)
SecurityCORS explicitly configured
PerformanceCache-Control on GET responses
PerformanceGZIP compression enabled
DocumentationOpenAPI spec generated and up to date
MonitoringCorrelation ID in all logs
ErrorsConsistent error messages (ApiErrorResponse)
ErrorsNo 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}

TermDefinition
API GatewayCentralized entry point managing routing, authentication, and policies
APIMAzure API Management - Azure service for API management
CORSCross-Origin Resource Sharing - cross-domain resource sharing mechanism
CRUDCreate, Read, Update, Delete - basic data operations
ETagEntity Tag - resource version identifier for optimistic concurrency
HTTP MethodHTTP verb defining the action (GET, POST, PUT, PATCH, DELETE)
IdempotenceThe same request can be repeated without different effect (GET, PUT, DELETE)
If-MatchHTTP header for optimistic concurrency - checks ETag before modification
OAuth 2.0Standard authorization protocol for secured APIs
OpenAPIREST API description standard (formerly Swagger)
PaginationSplitting large lists into pages to optimize performance
PolicyAPIM rule applied to requests/responses (XML)
Rate LimitingLimiting the number of requests per period
RESTRepresentational State Transfer - architectural style for web APIs
Subscription KeyClient identification key in APIM
ThrottlingLimiting bandwidth or request throughput
VersioningMechanism for managing API versions
WebhookHTTP notification sent by a service to your API when an event occurs

REST Principles Summary

PrincipleDescription
StatelessEach request is independent, no server-side session
HTTP MethodsGET (read), POST (create), PUT (replace), PATCH (update), DELETE (delete)
Format-agnosticJSON, XML, Protocol Buffers, etc.
Loose couplingClient 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

TierUsage
ConsumptionServerless, pay-per-call, no SLA above 99.9%
DeveloperTest/dev, no production SLA
BasicSmall teams, production
StandardMid-sized teams, basic multi-region
PremiumEnterprise, 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

ConceptEssential
Azure Functions as REST APIURL pattern = <appname>.azurewebsites.net/api/<function>
Separate functions per HTTP methodSame route, separate functions → independent scaling and maintenance
APIMCentralized gateway: subscription keys, policies, developer portal
Import Azure Functions into APIMAuto-discovery of functions from the Function App
OpenAPI exportAPIM → Export → YAML/JSON for documentation and SDK generation
Route rewriting via APIM policiesTransform 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

MethodRouteSemanticsBodySuccess Response
GET/productsList the collectionNo200 OK + array
GET/products/{id}Read an elementNo200 OK + object
POST/productsCreate an elementYes (JSON)201 Created + Location header
PUT/products/{id}Replace entirelyYes (complete JSON)200 OK or 204 No Content
PATCH/products/{id}Modify partiallyYes (delta JSON)200 OK or 204 No Content
DELETE/products/{id}DeleteNo204 No Content
HEAD/products/{id}Check existenceNo200 OK (without body)
OPTIONS/productsCORS preflightNo204 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:

  1. Export from APIM (seen in module 3) — recommended for existing APIs.
  2. 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

EndpointDescription
GET /api/swagger/uiInteractive Swagger UI
GET /api/swagger.jsonOpenAPI 3.0 spec in JSON
GET /api/swagger.yamlOpenAPI 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;
}
HeaderMeaning
SunsetEnd-of-life date for the version (RFC 8594)
DeprecationIndicates 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;
    }
}
{
  "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

ParameterTypeExampleDescription
$topint$top=20Number of items to return
$skipint$skip=40Number of items to skip (offset)
$filterstring$filter=category eq 'electronics'OData filter
$orderbystring$orderby=price descSort
$selectstring$select=id,name,priceField projection
$countbool$count=trueInclude total count
continuationTokenstring(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

HeaderPurposeExample
Cache-ControlCache directives for proxies and clientspublic, max-age=300
ETagResource version identifier"abc123def456"
Last-ModifiedLast modification dateWed, 21 Oct 2024 07:28:00 GMT
VaryIndicates headers that vary the responseAccept-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

Interested in this course?

Contact us to book it or get a custom training plan for your team.