Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ using Foundatio.Mediator;
[assembly: MediatorConfiguration(
HandlerLifetime = MediatorLifetime.Scoped,
EndpointDiscovery = EndpointDiscovery.All,
EndpointRoutePrefix = "api"
EndpointRoutePrefix = "api",
ApiVersions = ["1", "2"],
ApiVersionHeader = "Api-Version"
)]
```

Expand Down Expand Up @@ -181,6 +183,19 @@ The following properties on `MediatorConfigurationAttribute` control endpoint ge
- **Important:** Group-level `RoutePrefix` values without a leading `/` are **relative** to this global prefix. Don't include `api` in your group prefixes when using the default global prefix, or you'll get `/api/api/...`. Use a leading `/` on a group prefix to make it absolute (bypasses the global prefix).
- **To disable:** Set `EndpointRoutePrefix = ""` to remove the global prefix entirely, then use full paths in group prefixes.

**`ApiVersions`** (`string[]?`)

- **Default:** `null` (versioning disabled)
- **Effect:** Declares the set of API versions your application supports. When specified, handlers annotated with `ApiVersion` are dispatched based on a request header rather than URL path segments. Routes stay flat (e.g., `/api/products`) regardless of version.
- **Example:** `ApiVersions = ["1", "2"]` — the last entry is treated as the latest (default) version
- **See:** [Endpoints Guide — API Versioning](/guide/endpoints#api-versioning) for full documentation

**`ApiVersionHeader`** (`string`)

- **Default:** `"Api-Version"`
- **Effect:** Sets the HTTP request header used to select the API version at runtime. Only used when `ApiVersions` is set.
- **Example:** `ApiVersionHeader = "X-Api-Version"` — clients send `X-Api-Version: 2` to request version 2

**`AuthorizationRequired`** (`bool`)

- **Default:** `false`
Expand Down
129 changes: 129 additions & 0 deletions docs/guide/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ Everything works out of the box with smart defaults. Attributes are only needed

`[HandlerEndpointGroup]` is applied to a handler **class** and controls all endpoints in that class as a group. Use it to set a shared route prefix, OpenAPI tag, or endpoint filters for every handler method on the class.

The group name is optional — when omitted, it's derived from the class name (e.g., `ProductHandler` → `Products`):

```csharp
[HandlerEndpointGroup(RoutePrefix = "v2/products")]
public class ProductHandler
Expand Down Expand Up @@ -795,6 +797,133 @@ public class ProductHandler

The generated endpoints will include these summaries in their OpenAPI metadata — visible in Swagger UI, Scalar, and any other OpenAPI tooling. You can also override the summary per-endpoint using `[HandlerEndpoint(Summary = "...")]`.

## API Versioning

Foundatio Mediator supports Stripe-style header-based API versioning. Routes stay the same across all versions — the client sends an `Api-Version` header to select which version they want. When no header is sent, the latest declared version is used by default.

### Declaring Versions

Declare your API versions at the assembly level:

```csharp
[assembly: MediatorConfiguration(
ApiVersions = ["1", "2"], // All declared versions
ApiVersionHeader = "Api-Version" // Header name (default)
)]
```

When `ApiVersions` is not set, no versioning logic is generated — everything works exactly as before (backward-compatible).

### Basic Usage — Unversioned Handlers

Most handlers need zero versioning boilerplate. Handlers without `ApiVersion` serve all versions automatically:

```csharp
public class ProductHandler
{
// Available in ALL versions — no annotation needed
public Result<Product> Handle(GetProduct query) { ... }
public Result<Product> Handle(CreateProduct command) { ... }
}
```

### Version-Specific Handlers

When a breaking change is needed, create a separate handler class with an explicit `ApiVersion`. It overrides the default handler for that version on the same route:

```csharp
[HandlerEndpointGroup(ApiVersion = "2")]
public class ProductV2Handler
{
// Overrides GetProduct for version 2 only — returns a different DTO
public Result<ProductDto> Handle(GetProduct query) { ... }
}
```

The generator detects that both `ProductHandler` and `ProductV2Handler` handle `GetProduct` on the same route, and emits a single endpoint with header-based dispatch:

```text
GET /api/products/{productId}
→ Api-Version: 1 → ProductHandler.Handle(GetProduct)
→ Api-Version: 2 → ProductV2Handler.Handle(GetProduct) (default, latest)
→ No header → ProductV2Handler.Handle(GetProduct) (defaults to latest)
```

Non-overridden endpoints (like `CreateProduct`) are served by the unversioned handler regardless of the version header.

### Method-Level Version Override

Override the group version on individual methods:

```csharp
[HandlerEndpointGroup(ApiVersion = "1")]
public class WidgetHandler
{
// Inherits v1 from the group
public string Handle(GetWidgetV1 query) => "v1";

// Override to v2 for this method only
[HandlerEndpoint(ApiVersion = "2")]
public string Handle(GetWidgetV2 query) => "v2";
}
```

### Multi-Version Handlers

Expose a handler in specific versions without creating separate classes:

```csharp
[HandlerEndpointGroup(ApiVersions = ["1", "2"])]
public class ProductHandler
{
public Result<Product> Handle(GetProduct query) { ... }
}
```

### Deprecating Versions

Mark a version as deprecated to signal consumers it will be removed:

```csharp
[HandlerEndpointGroup(ApiVersion = "1", Deprecated = true)]
public class ProductHandlerV1
{
public Result<Product> Handle(GetProduct query) { ... }
}
```

Deprecated endpoints emit `[Obsolete]` metadata in the generated OpenAPI specification.

### Custom Version Header

Change the header name used for version selection:

```csharp
[assembly: MediatorConfiguration(
ApiVersions = ["2024-01-15", "2025-03-01"],
ApiVersionHeader = "X-Api-Version" // Custom header name
)]
```

### Mixed Versioned and Unversioned Endpoints

Handlers without `ApiVersion` serve all versions automatically. Versioned handlers override specific routes for their version. Both coexist naturally:

```csharp
// No version — serves all versions at /api/health
public class HealthHandler
{
public string Handle(GetHealth query) => "ok";
}

// Serves all versions at /api/products (default)
public class ProductHandler { ... }

// Overrides specific routes for version 2 at /api/products
[HandlerEndpointGroup(ApiVersion = "2")]
public class ProductV2Handler { ... }
```

## Advanced Configuration

### Assembly Endpoint Options
Expand Down
16 changes: 8 additions & 8 deletions samples/CleanArchitectureSample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A working modular monolith that showcases Foundatio.Mediator's features in a rea
| **Authorization** | `[HandlerAuthorize(Roles = ["Admin"])]`, `[HandlerAllowAnonymous]`, global `AuthorizationRequired = true` |
| **Message validation** | `[Required]`, `[Range]`, `[StringLength]` on message records, enforced by `ValidationMiddleware` |
| **Endpoint generation** | `MapMediatorEndpoints()` auto-generates minimal API routes from handlers |
| **Endpoint groups & filters** | `[HandlerEndpointGroup("Orders", EndpointFilters = [typeof(SetRequestedByFilter)])]` |
| **Endpoint groups & filters** | `[HandlerEndpointGroup(EndpointFilters = [typeof(SetRequestedByFilter)])]` |
| **Middleware ordering** | `OrderBefore`/`OrderAfter` declarative dependencies between middleware |
| **Module-scoped middleware** | `OrdersModuleMiddleware`, `ProductsModuleMiddleware` run only for their module's messages |
| **Multiple cascading events** | `UpdateProduct` returns `(Result<Product>, ProductUpdated?, ProductStockChanged?)` |
Expand Down Expand Up @@ -394,7 +394,7 @@ The pieces that make this work:
When any handler in any module publishes a cascading event marked with `IDispatchToClient`, it automatically appears in the SSE stream — no additional wiring needed. The frontend connects using the browser's `EventSource` API:

```javascript
const source = new EventSource('/events/stream');
const source = new EventSource('/api/events');
source.onmessage = (e) => {
const event = JSON.parse(e.data);
console.log(event.eventType, event.data);
Expand Down Expand Up @@ -467,27 +467,27 @@ The SPA Proxy starts the Vite dev server automatically.
| URL | Description |
| --- | ----------- |
| `https://localhost:5173` | SvelteKit frontend |
| `https://localhost:58702/api/*` | Backend API |
| `https://localhost:58702/scalar/v1` | API docs (Scalar) |
| `https://localhost:5702/api/*` | Backend API |
| `https://localhost:5702/scalar` | API docs (Scalar, latest) |

### Try the API

```bash
# Create a product (requires Admin login)
curl -X POST https://localhost:58702/api/products \
curl -X POST https://localhost:5702/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Widget","description":"A great widget","price":29.99,"stockQuantity":50}'

# Create an order
curl -X POST https://localhost:58702/api/orders \
curl -X POST https://localhost:5702/api/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"customer-123","amount":29.99,"description":"Widget purchase"}'

# Dashboard report (aggregates from both modules)
curl https://localhost:58702/api/reports
curl https://localhost:5702/api/reports

# Search across modules
curl "https://localhost:58702/api/reports/search-catalog?searchTerm=widget"
curl "https://localhost:5702/api/reports/search-catalog?searchTerm=widget"
```

Demo users: `admin`/`admin` (Admin role), `user`/`user` (User role).
Expand Down
2 changes: 1 addition & 1 deletion samples/CleanArchitectureSample/src/Api/Api.http
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@baseUrl = https://localhost:58702
@baseUrl = https://localhost:5702

###############################################################################
# CLEAN ARCHITECTURE DEMO - Key Features
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using Common.Module;
using Foundatio.Mediator;

[assembly: MediatorConfiguration(
EnableGenerationCounter = true,
AuthorizationRequired = true,
MiddlewareLifetime = MediatorLifetime.Singleton
MiddlewareLifetime = MediatorLifetime.Singleton,
ApiVersions = [ApiConstants.V1, ApiConstants.V2],
ApiVersionHeader = ApiConstants.VersionHeader
)]
35 changes: 3 additions & 32 deletions samples/CleanArchitectureSample/src/Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,60 +1,31 @@
using Common.Module;
using Foundatio.Mediator;
using Microsoft.AspNetCore.Authentication.Cookies;
using Orders.Module;
using Products.Module;
using Reports.Module;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddOpenApi();

// Simple cookie authentication for the sample
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "ModularMonolith.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
// Return 401 JSON instead of redirecting to a login page
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
builder.Services.AddOpenApiDocs(ApiConstants.AllVersions);
builder.Services.AddDemoAuthentication();

// Add Foundatio.Mediator — all referenced module assemblies are auto-discovered
builder.Services.AddMediator();

// Add module services
// Order matters: Common.Module provides cross-cutting services that other modules may depend on
builder.Services.AddCommonModule();
builder.Services.AddOrdersModule();
builder.Services.AddProductsModule();
builder.Services.AddReportsModule();

// Cross-module event handlers (AuditEventHandler, NotificationEventHandler) are now
// in Common.Module and will be discovered automatically via the source generator

var app = builder.Build();

// Serve static files from the SPA
app.UseDefaultFiles();
app.MapStaticAssets();

app.MapOpenApi();
app.MapScalarApiReference();
app.MapOpenApiDocs("Modular Monolith API", ApiConstants.AllVersions);

app.UseHttpsRedirection();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
},
"applicationUrl": "https://localhost:58702;http://localhost:58703"
"applicationUrl": "https://localhost:5702;http://localhost:5703"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Scalar.AspNetCore;

namespace Microsoft.Extensions.DependencyInjection;

public static class WebApplicationExtensions
{
/// <summary>
/// Registers one OpenAPI document per API version.
/// The generated <c>ApiVersionOpenApiProvider</c> assigns endpoints to the
/// correct version document based on <c>ApiVersionMetadata</c>.
/// </summary>
public static IServiceCollection AddOpenApiDocs(this IServiceCollection services, params string[] versions)
{
foreach (var version in versions)
{
var docName = version.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? version : "v" + version;
services.AddOpenApi(docName);
}

return services;
}

/// <summary>
/// Maps OpenAPI endpoints and Scalar API reference for all version documents.
/// The latest version (last in the array) is shown by default at /scalar.
/// </summary>
public static WebApplication MapOpenApiDocs(this WebApplication app, string title, params string[] versions)
{
app.MapOpenApi();

app.MapScalarApiReference(options =>
{
options.WithTitle(title);
options.SortTagsAlphabetically();
for (int i = 0; i < versions.Length; i++)
{
var docName = versions[i].StartsWith("v", StringComparison.OrdinalIgnoreCase) ? versions[i] : "v" + versions[i];
var isLatest = i == versions.Length - 1;
options.AddDocument(docName, isDefault: isLatest);
}
});

return app;
}

/// <summary>
/// Adds simple cookie authentication for the sample application.
/// Returns 401/403 JSON responses instead of redirecting to a login page.
/// </summary>
public static IServiceCollection AddDemoAuthentication(this IServiceCollection services)
{
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "ModularMonolith.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
services.AddAuthorization();

return services;
}

}

Loading
Loading