DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

Azure API Management Part 2: A Complete CRUD API, Built and Deployed Behind APIM End to End

Part 1 covered the concepts - what APIM is, the Product and API and Operation hierarchy, policies, subscription keys, OAuth validation, CORS, and rate limiting. This part builds something real: a complete CRUD API in ASP.NET Core, imported into APIM, wrapped in a Product, and secured with every policy from Part 1 actually applied and working - the full path from code to a production-shaped, secured API.

Step 1: Building a Minimal CRUD API

A small Notes API - Create, Read, Update, Delete - is enough to demonstrate every APIM concept without unnecessary complexity. Swagger and OpenAPI support are enabled from the start, since that's what makes importing into APIM trivial in the next step.

// Program.cs - minimal API style, ASP.NET Core

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();

var notes = new List<Note>();

app.MapGet("/notes", () => notes);

app.MapGet("/notes/{id}", (int id) =>
{
    var note = notes.FirstOrDefault(n => n.Id == id);
    return note is not null ? Results.Ok(note) : Results.NotFound();
});

app.MapPost("/notes", (Note note) =>
{
    note.Id = notes.Count + 1;
    notes.Add(note);
    return Results.Created($"/notes/{note.Id}", note);
});

app.MapPut("/notes/{id}", (int id, Note updated) =>
{
    var note = notes.FirstOrDefault(n => n.Id == id);
    if (note is null) return Results.NotFound();
    note.Title = updated.Title;
    note.Content = updated.Content;
    return Results.Ok(note);
});

app.MapDelete("/notes/{id}", (int id) =>
{
    var note = notes.FirstOrDefault(n => n.Id == id);
    if (note is null) return Results.NotFound();
    notes.Remove(note);
    return Results.NoContent();
});

app.Run();

record Note
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Content { get; set; } = "";
}
Enter fullscreen mode Exit fullscreen mode

Deployed to an App Service, the same pattern covered when this blog's own API was set up, the Swagger UI at /swagger now serves a machine-readable OpenAPI spec at /swagger/v1/swagger.json - this file is what APIM will import directly in the next step.

Step 2: Importing the API Into APIM

In the Azure Portal, inside an existing APIM instance, the import flow starts at APIs, then Add API, then OpenAPI. Pasting the URL to the swagger.json file, or uploading it directly, along with a Display Name like "Notes API" and an API URL suffix like "notes," is enough for APIM to read the spec and automatically create every Operation - GET /notes, GET /notes/{id}, POST /notes, PUT /notes/{id}, and DELETE /notes/{id} - with their parameters already defined.

No manual operation-by-operation setup is required. This is the entire benefit of enabling Swagger and OpenAPI on the API itself before ever touching APIM - the import step becomes almost entirely automatic.

Step 3: Creating a Product and Adding the API to It

A new Product named "Partner Notes Access" is created, with a description explaining it grants access to the Notes API for approved partners, subscription required, and approval required so that a partner requesting access gets manually reviewed before being granted a key. The Notes API is then added to that Product directly. The Notes API is now reachable only through a valid subscription to this specific Product, not by anyone who simply discovers the URL.

Step 4: Layering the Policies From Part 1

The Product-level policy handles the subscription key check, which is largely automatic once "Requires subscription" is enabled on the Product itself. The underlying policy structure still exists as an inbound block with a base element, even though the actual key validation happens automatically based on that Product setting.

The API-level policy handles OAuth validation, applying to every operation in the Notes API, layered on top of the product-level subscription check:

<!-- Notes API-level policy.xml -->
<policies>
  <inbound>
    <base />
    <validate-jwt header-name="Authorization"
        failed-validation-httpcode="401"
        failed-validation-error-message="Unauthorized">
      <openid-config url="https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration" />
      <audiences>
        <audience>api://notes-api</audience>
      </audiences>
    </validate-jwt>
  </inbound>
</policies>
Enter fullscreen mode Exit fullscreen mode

The Operation-level policy applies a stricter rate limit specifically to the write operations - POST, PUT, and DELETE - leaving GET at the product's default limit:

<!-- Applied to POST /notes, PUT /notes/{id},
     DELETE /notes/{id} individually -->
<policies>
  <inbound>
    <base />
    <rate-limit-by-key calls="5"
        renewal-period="60"
        counter-key="@(context.Subscription.Id)" />
  </inbound>
</policies>
Enter fullscreen mode Exit fullscreen mode

Write operations get five calls per minute, meaningfully stricter than the read-only GET operations, since writes are more expensive and more consequential if something calls them in a runaway loop.

The actual effective policy for a POST request, once all three levels combine, runs in a specific order: the subscription key is checked first at the Product level, inherited automatically; then the OAuth token is validated at the API level, also inherited; then the stricter rate limit applies at the Operation level, specific to this operation alone - all three running in sequence before the request ever reaches the real backend.

Step 5: Adding CORS for a Real Frontend Origin

<!-- API-level policy, added alongside the JWT validation -->
<inbound>
  <base />
  <validate-jwt ... />

  <cors allow-credentials="true">
    <allowed-origins>
      <origin>https://partner-app.example.com</origin>
    </allowed-origins>
    <allowed-methods>
      <method>GET</method>
      <method>POST</method>
      <method>PUT</method>
      <method>DELETE</method>
    </allowed-methods>
    <allowed-headers>
      <header>Content-Type</header>
      <header>Authorization</header>
    </allowed-headers>
  </cors>
</inbound>
Enter fullscreen mode Exit fullscreen mode

Setting allow-credentials to true means the Authorization header, carrying the OAuth token, is permitted - which is exactly why the origin must be named explicitly as https://partner-app.example.com rather than a wildcard, per the CORS rule covered in Part 1: browsers explicitly forbid combining a wildcard origin with credentialed requests.

Step 6: Versioning Before the Breaking Change Ships

Consider a scenario where the Notes API's response shape needs to change - "Content" is being renamed to "Body" to better match a new internal naming convention. This breaks every existing caller still expecting "Content."

The fix starts under APIs, then Notes API, then Add Version, choosing a Path-based versioning scheme for the discoverability reasoning covered in Part 1, with a version identifier of v2. The v2 API is now live at /v2/notes, completely separate from the original, which APIM automatically prefixes as v1 once a second version exists.

The v2 backend returns a response shaped as Id, Title, and Body, while the v1 backend still returns Id, Title, and Content - both versions can point to different backend deployments, or the same backend with a version-aware response transformation. Existing partners keep calling /v1/notes with zero changes required on their end. New partners, or partners ready to migrate, start using /v2/notes explicitly and get the new Body field name.

Step 7: Testing the Complete Flow End to End

What a caller actually experiences, request by request, demonstrates every layer working together.

  • A POST request with no subscription key returns 401 Unauthorized, blocked at the Product level before the backend is ever called.

  • The same request with a subscription key added but no Authorization header still returns 401 Unauthorized, this time blocked at the API level.

  • The same request with both a valid subscription key and a valid OAuth token, called six times within one minute, succeeds for the first five calls but the sixth returns 429 Too Many Requests, blocked at the Operation level's rate limit.

  • Only a request with a valid key, a valid token, and within the rate limit actually reaches the backend and returns 201 Created with the note genuinely persisted.

Every failure in that sequence was caught by APIM before the backend ever ran - exactly the architectural benefit covered conceptually in Part 1, now demonstrated with an actual request sequence.

Key Lessons

Enabling Swagger and OpenAPI on an API before importing into APIM turns manual operation-by-operation setup into an automatic import.

Real production APIM setups layer policies across all three levels deliberately - subscription key at the product level, identity at the API level, fine-grained limits at the operation level.

Write operations often warrant stricter rate limits than reads, applied specifically at the operation level rather than uniformly across the whole API.

CORS with credentials requires a named origin, never a wildcard - this is not optional, browsers enforce it directly.

Versioning before a breaking change ships, not after, is what lets existing callers continue working completely unaffected while new callers adopt the new version deliberately.

Testing the full request sequence - missing key, missing token, rate limit exceeded, successful call - demonstrates that every layer of protection actually runs before the backend, not just in theory.

The Two-Part Series, Complete

Part 1 covered the concepts - what APIM is, the Product and API and Operation hierarchy, policies, subscription keys, OAuth, CORS, and rate limiting. Part 2 built all of it into a real, working example - a CRUD API imported into APIM, secured with layered policies, versioned safely, and tested end to end.

Summary

The concepts from Part 1 only fully make sense once they're applied together against something real. A minimal CRUD API, imported via its OpenAPI spec, becomes a fully governed API once it sits inside a Product with a subscription requirement, an API-level OAuth check, an operation-level rate limit on the expensive write paths, and a properly scoped CORS policy for its actual frontend. Versioning before a breaking change ships, rather than scrambling after callers start failing, is what keeps that governance sustainable over time. This is the complete, real path from a few lines of C# to a production-shaped, secured API, not just the individual pieces in isolation.


Originally published at TechStack Blog: https://www.techstackblog.com/post.html?slug=azure-apim-explained-part2

Part 1 of this series (Concepts): https://www.techstackblog.com/post.html?slug=azure-apim-explained-part1

More from TechStack Blog: Azure: https://www.techstackblog.com/category.html?cat=azure
C# / .NET: https://www.techstackblog.com/category.html?cat=csharp

Top comments (0)