diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4628d5..07784ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,19 @@ as `0.0.0-preview.*` from the `dev` branch.
## [Unreleased]
+### Changed
+- **Path keys are now relative to the API base**; the Functions route prefix (`api` by default) is
+ advertised via the document's top-level `servers` array instead of being repeated on every path
+ (e.g. `/items` with `servers: [{ "url": ".../api" }]`, not `/api/items`). This is the idiomatic
+ OpenAPI representation of a shared base path and is friendlier to client generators. The server URL
+ is inferred from the incoming request by default (scheme/host/path base, honouring
+ `X-Forwarded-Proto` / `X-Forwarded-Host`), with a relative fallback; set the new
+ `OpenApiOptions.Servers` to advertise fixed URLs instead. **Consumers relying on `/api` in the
+ generated path keys will need to update.**
+
+### Added
+- `OpenApiOptions.Servers` for supplying explicit `servers` entries (overrides request inference).
+
## [0.1.1] - 2026-09-01
### Fixed
diff --git a/README.md b/README.md
index 298dd91..972d7f3 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,32 @@ Once registered, the app serves (using the default `api` route prefix):
- `GET /api/openapi.yaml` — the OpenAPI document as YAML
- `GET /api/swagger` — an interactive [Swagger UI](#swagger-ui) page (enabled by default)
+## Base path and `servers`
+
+The generated **path keys are relative to the API base** — for example `/items`, not `/api/items`.
+The Functions host route prefix (`api` by default) is a global base path, so it is advertised once
+via the document's top-level `servers` array, the idiomatic OpenAPI way to express a shared base URL
+(an operation's effective URL is `servers[].url` + path key):
+
+```jsonc
+"servers": [ { "url": "https://your-app.azurewebsites.net/api" } ],
+"paths": { "/items": { }, "/items/{id}": { } }
+```
+
+By default the server URL is **inferred from the incoming request** — scheme, host, and path base,
+honouring `X-Forwarded-Proto` / `X-Forwarded-Host` — so the advertised base matches wherever the
+document is served from (local dev, deployed, or behind a reverse proxy). If the host cannot be
+determined, it falls back to a relative base path (`/api`). To advertise fixed URLs instead (for
+example a public API gateway), set `OpenApiOptions.Servers`; those entries are used verbatim and
+request inference is skipped:
+
+```csharp
+services.AddOpenApi(options =>
+{
+ options.Servers.Add(new OpenApiServer { Url = "https://api.contoso.com/v1", Description = "Production" });
+});
+```
+
## Install
```bash
@@ -565,7 +591,8 @@ Configure via the `AddOpenApi(options => { ... })` callback (`OpenApiOptions`):
| `Title` | `"OpenAPI Document"` | `info.title` of the document |
| `Version` | `"1.0.0"` | `info.version` (your API version) |
| `Description` | `null` | `info.description` |
-| `RoutePrefix` | `"api"` | Functions host route prefix, used to advertise endpoint URLs |
+| `RoutePrefix` | `"api"` | Functions host route prefix; advertised as the base path via `servers` (not repeated on path keys) |
+| `Servers` | *(empty)* | Explicit `servers` entries; when empty, one server is inferred from the request (host/scheme/path base, honouring `X-Forwarded-*`) |
| `JsonRoute` | `"openapi.json"` | Route (relative to `RoutePrefix`) serving JSON |
| `YamlRoute` | `"openapi.yaml"` | Route (relative to `RoutePrefix`) serving YAML |
| `SpecVersion` | `OpenApi3_1` | OpenAPI Specification version to serialize against |
diff --git a/src/AzureExtensions.Functions.OpenApi/Discovery/FunctionEndpointDiscovery.cs b/src/AzureExtensions.Functions.OpenApi/Discovery/FunctionEndpointDiscovery.cs
index f7e941b..066d35c 100644
--- a/src/AzureExtensions.Functions.OpenApi/Discovery/FunctionEndpointDiscovery.cs
+++ b/src/AzureExtensions.Functions.OpenApi/Discovery/FunctionEndpointDiscovery.cs
@@ -58,11 +58,13 @@ internal sealed class FunctionEndpointDiscovery
/// The assemblies to scan. When empty, a default set is resolved via
/// .
///
- ///
- /// The host route prefix (for example api). May be empty to serve from the root.
- ///
/// The discovered endpoints, in discovery order.
- public IReadOnlyList Discover(IEnumerable assemblies, string routePrefix)
+ ///
+ /// Emitted path keys are relative to the host route prefix (for example /widgets, not
+ /// /api/widgets). The prefix is a global host setting and is advertised via the
+ /// document's servers base URL rather than repeated on every path.
+ ///
+ public IReadOnlyList Discover(IEnumerable assemblies)
{
var candidates = (assemblies ?? []).Where(static a => a is not null).Distinct().ToList();
if (candidates.Count == 0)
@@ -78,7 +80,7 @@ public IReadOnlyList Discover(IEnumerable assembli
{
foreach (var method in GetMethodsSafe(type))
{
- var endpoint = TryDiscover(method, routePrefix);
+ var endpoint = TryDiscover(method);
if (endpoint is not null)
{
results.Add(endpoint);
@@ -121,7 +123,7 @@ public IEnumerable GetDefaultAssemblies()
return result;
}
- private static DiscoveredEndpoint? TryDiscover(MethodInfo method, string routePrefix)
+ private static DiscoveredEndpoint? TryDiscover(MethodInfo method)
{
try
{
@@ -147,7 +149,7 @@ public IEnumerable GetDefaultAssemblies()
? functionAttribute.Name
: httpTrigger.Route!;
- var path = CombinePath(routePrefix, template);
+ var path = CombinePath(template);
var routeParameters = ExtractRouteParameters(template);
return new DiscoveredEndpoint(path, methods, method, routeParameters);
@@ -193,12 +195,12 @@ private static IReadOnlyList ExtractRouteParameters(string? template)
return names;
}
- private static string CombinePath(string? routePrefix, string? route)
+ private static string CombinePath(string? route)
{
var normalizedRoute = NormalizeRouteTokens(route);
- var segments = new[] { routePrefix, normalizedRoute }
- .SelectMany(static s => (s ?? string.Empty).Split('/', StringSplitOptions.RemoveEmptyEntries));
+ var segments = ((normalizedRoute ?? string.Empty))
+ .Split('/', StringSplitOptions.RemoveEmptyEntries);
return "/" + string.Join('/', segments);
}
diff --git a/src/AzureExtensions.Functions.OpenApi/OpenApiDocumentProvider.cs b/src/AzureExtensions.Functions.OpenApi/OpenApiDocumentProvider.cs
index 1b45d9b..9b94ce8 100644
--- a/src/AzureExtensions.Functions.OpenApi/OpenApiDocumentProvider.cs
+++ b/src/AzureExtensions.Functions.OpenApi/OpenApiDocumentProvider.cs
@@ -81,7 +81,7 @@ private OpenApiDocument BuildDocument()
? _options.DocumentAssemblies
: discovery.GetDefaultAssemblies();
- var endpoints = discovery.Discover(assemblies, _options.RoutePrefix);
+ var endpoints = discovery.Discover(assemblies);
// The default assembly scan can include this library's own assembly, which declares the
// openapi.json/openapi.yaml meta-endpoints. The served spec must describe the CONSUMER's
diff --git a/src/AzureExtensions.Functions.OpenApi/OpenApiHttpFunctions.cs b/src/AzureExtensions.Functions.OpenApi/OpenApiHttpFunctions.cs
index 5ca6486..f559ac9 100644
--- a/src/AzureExtensions.Functions.OpenApi/OpenApiHttpFunctions.cs
+++ b/src/AzureExtensions.Functions.OpenApi/OpenApiHttpFunctions.cs
@@ -56,7 +56,7 @@ public async Task GetOpenApiJson(
_logger.LogDebug("Serving OpenAPI document as JSON at spec version {SpecVersion}.", _options.SpecVersion);
var document = await _documentProvider.GetDocumentAsync(request.HttpContext.RequestAborted).ConfigureAwait(false);
- var json = Serialize(document, "json", _options.SpecVersion);
+ var json = Serialize(WithServers(document, request), "json", _options.SpecVersion);
return Results.Text(json, JsonContentType, statusCode: StatusCodes.Status200OK);
}
@@ -73,7 +73,7 @@ public async Task GetOpenApiYaml(
_logger.LogDebug("Serving OpenAPI document as YAML at spec version {SpecVersion}.", _options.SpecVersion);
var document = await _documentProvider.GetDocumentAsync(request.HttpContext.RequestAborted).ConfigureAwait(false);
- var yaml = Serialize(document, "yaml", _options.SpecVersion);
+ var yaml = Serialize(WithServers(document, request), "yaml", _options.SpecVersion);
return Results.Text(yaml, YamlContentType, statusCode: StatusCodes.Status200OK);
}
@@ -114,16 +114,75 @@ public IResult GetSwaggerUi(
///
private string BuildJsonUrl(HttpRequest request)
{
- var pathBase = request.PathBase.HasValue ? request.PathBase.Value!.Trim('/') : string.Empty;
var prefix = _options.RoutePrefix.Trim('/');
var jsonRoute = _options.JsonRoute.Trim('/');
+ var suffix = string.Join('/', new[] { prefix, jsonRoute }.Where(static s => !string.IsNullOrEmpty(s)));
+
+ var baseUrl = ResolveRequestBaseUrl(request);
+ if (baseUrl is null)
+ {
+ return "/" + suffix;
+ }
+
+ return string.IsNullOrEmpty(suffix) ? baseUrl : $"{baseUrl}/{suffix}";
+ }
+
+ ///
+ /// Produces the per-request servers list for the document. When
+ /// is configured it is used verbatim; otherwise a single
+ /// server is inferred from the request base URL (scheme/host/path base, honoring forwarded
+ /// headers) combined with . When the host cannot be
+ /// resolved the URL falls back to a relative base path so the document is still valid.
+ ///
+ private IList BuildServers(HttpRequest request)
+ {
+ if (_options.Servers.Count > 0)
+ {
+ return _options.Servers.ToList();
+ }
+
+ var prefix = _options.RoutePrefix.Trim('/');
+ var baseUrl = ResolveRequestBaseUrl(request);
+
+ string url;
+ if (baseUrl is null)
+ {
+ url = string.IsNullOrEmpty(prefix) ? "/" : "/" + prefix;
+ }
+ else
+ {
+ url = string.IsNullOrEmpty(prefix) ? baseUrl : $"{baseUrl}/{prefix}";
+ }
- var path = string.Join('/', new[] { pathBase, prefix, jsonRoute }.Where(s => !string.IsNullOrEmpty(s)));
+ return new List { new() { Url = url } };
+ }
+
+ ///
+ /// Returns a per-request shallow copy of the cached document with its servers set. The
+ /// cached document is shared across requests, so it is never mutated; the copy carries the
+ /// request-specific server list while sharing the (read-only during serialization) paths and
+ /// components.
+ ///
+ private OpenApiDocument WithServers(OpenApiDocument document, HttpRequest request) =>
+ new(document) { Servers = BuildServers(request) };
+ ///
+ /// Resolves the request's public-facing base URL as {scheme}://{host} (plus the request
+ /// path base when present), honoring X-Forwarded-Proto / X-Forwarded-Host. Returns
+ /// when no host can be determined.
+ ///
+ private static string? ResolveRequestBaseUrl(HttpRequest request)
+ {
var scheme = ResolveForwardedValue(request, "X-Forwarded-Proto") ?? request.Scheme;
var host = ResolveForwardedValue(request, "X-Forwarded-Host") ?? request.Host.Value;
+ if (string.IsNullOrEmpty(host))
+ {
+ return null;
+ }
- return $"{scheme}://{host}/{path}";
+ var pathBase = request.PathBase.HasValue ? request.PathBase.Value!.Trim('/') : string.Empty;
+ var baseUrl = $"{scheme}://{host}";
+ return string.IsNullOrEmpty(pathBase) ? baseUrl : $"{baseUrl}/{pathBase}";
}
///
diff --git a/src/AzureExtensions.Functions.OpenApi/OpenApiOptions.cs b/src/AzureExtensions.Functions.OpenApi/OpenApiOptions.cs
index 0bd0c73..2822c06 100644
--- a/src/AzureExtensions.Functions.OpenApi/OpenApiOptions.cs
+++ b/src/AzureExtensions.Functions.OpenApi/OpenApiOptions.cs
@@ -30,10 +30,22 @@ public sealed class OpenApiOptions
///
/// The route prefix the Functions host uses for HTTP triggers. Defaults to api,
- /// matching the Azure Functions default. Used to advertise the effective endpoint URLs.
+ /// matching the Azure Functions default. This is the base path for the API and is advertised
+ /// via the document's servers entry rather than repeated on every path key. It also
+ /// drives the effective URLs of the served document/Swagger UI endpoints.
///
public string RoutePrefix { get; set; } = "api";
+ ///
+ /// Optional explicit servers for the generated document. When empty (the default), a
+ /// single server is inferred from the incoming request (scheme/host, honoring
+ /// X-Forwarded-Proto / X-Forwarded-Host and the request path base) combined with
+ /// , so the advertised base URL matches wherever the document is served
+ /// from. Add entries here to advertise fixed URLs instead (for example a public API gateway),
+ /// in which case they are used verbatim and request inference is skipped.
+ ///
+ public IList Servers { get; } = new List();
+
///
/// The route (relative to ) that serves the JSON document.
///
diff --git a/tests/AzureExtensions.Functions.OpenApi.Tests/DiscoveryTests.cs b/tests/AzureExtensions.Functions.OpenApi.Tests/DiscoveryTests.cs
index 1ecb4c9..74ef07d 100644
--- a/tests/AzureExtensions.Functions.OpenApi.Tests/DiscoveryTests.cs
+++ b/tests/AzureExtensions.Functions.OpenApi.Tests/DiscoveryTests.cs
@@ -42,10 +42,10 @@ public void TimerThing() { }
///
public sealed class DiscoveryTests
{
- private static IReadOnlyList DiscoverFakes(string routePrefix = "api")
+ private static IReadOnlyList DiscoverFakes()
{
var discovery = new FunctionEndpointDiscovery();
- return discovery.Discover(new[] { typeof(DiscoveryFakeFunctions).Assembly }, routePrefix);
+ return discovery.Discover(new[] { typeof(DiscoveryFakeFunctions).Assembly });
}
private static DiscoveredEndpoint Find(IReadOnlyList endpoints, string function) =>
@@ -71,11 +71,11 @@ public void Discover_IgnoresNonHttpTriggeredFunctions()
}
[Fact]
- public void Discover_BuildsPrefixedPath_AndStripsRouteConstraints()
+ public void Discover_BuildsRelativePath_AndStripsRouteConstraints()
{
var endpoint = Find(DiscoverFakes(), nameof(DiscoveryFakeFunctions.GetThing));
- Assert.Equal("/api/things/{id}", endpoint.Path);
+ Assert.Equal("/things/{id}", endpoint.Path);
}
[Fact]
@@ -100,7 +100,7 @@ public void Discover_HandlesMultipleVerbs()
var endpoint = Find(DiscoverFakes(), nameof(DiscoveryFakeFunctions.MultiVerbThing));
Assert.Equal(new[] { "GET", "POST" }, endpoint.HttpMethods);
- Assert.Equal("/api/things", endpoint.Path);
+ Assert.Equal("/things", endpoint.Path);
}
[Fact]
@@ -108,7 +108,7 @@ public void Discover_FallsBackToFunctionName_WhenNoRoute()
{
var endpoint = Find(DiscoverFakes(), nameof(DiscoveryFakeFunctions.NoRouteThing));
- Assert.Equal("/api/NoRouteThing", endpoint.Path);
+ Assert.Equal("/NoRouteThing", endpoint.Path);
Assert.Empty(endpoint.RouteParameters);
}
@@ -117,26 +117,10 @@ public void Discover_StripsConstraintAndOptionalMarker_FromToken()
{
var endpoint = Find(DiscoverFakes(), nameof(DiscoveryFakeFunctions.OptionalTokenThing));
- Assert.Equal("/api/widgets/{key}", endpoint.Path);
+ Assert.Equal("/widgets/{key}", endpoint.Path);
Assert.Equal(new[] { "key" }, endpoint.RouteParameters);
}
- [Fact]
- public void Discover_HonorsCustomRoutePrefix()
- {
- var endpoint = Find(DiscoverFakes("v2"), nameof(DiscoveryFakeFunctions.GetThing));
-
- Assert.Equal("/v2/things/{id}", endpoint.Path);
- }
-
- [Fact]
- public void Discover_WithEmptyPrefix_ServesFromRoot()
- {
- var endpoint = Find(DiscoverFakes(""), nameof(DiscoveryFakeFunctions.MultiVerbThing));
-
- Assert.Equal("/things", endpoint.Path);
- }
-
[Fact]
public void GetDefaultAssemblies_ExcludesFrameworkAssemblies()
{
diff --git a/tests/AzureExtensions.Functions.OpenApi.Tests/DocumentGenerationTests.cs b/tests/AzureExtensions.Functions.OpenApi.Tests/DocumentGenerationTests.cs
index 4feea4f..f99ad54 100644
--- a/tests/AzureExtensions.Functions.OpenApi.Tests/DocumentGenerationTests.cs
+++ b/tests/AzureExtensions.Functions.OpenApi.Tests/DocumentGenerationTests.cs
@@ -47,8 +47,8 @@ public async Task Document_ContainsCrudPaths()
{
var document = await BuildSampleDocumentAsync();
- Assert.True(document.Paths!.ContainsKey("/api/items"));
- Assert.True(document.Paths!.ContainsKey("/api/items/{id}"));
+ Assert.True(document.Paths!.ContainsKey("/items"));
+ Assert.True(document.Paths!.ContainsKey("/items/{id}"));
}
[Fact]
@@ -56,8 +56,8 @@ public async Task Document_ContainsExpectedOperationsPerPath()
{
var document = await BuildSampleDocumentAsync();
- var items = Assert.IsType(document.Paths!["/api/items"]);
- var itemById = Assert.IsType(document.Paths!["/api/items/{id}"]);
+ var items = Assert.IsType(document.Paths!["/items"]);
+ var itemById = Assert.IsType(document.Paths!["/items/{id}"]);
Assert.True(items.Operations!.ContainsKey(HttpMethod.Get)); // ListItems
Assert.True(items.Operations!.ContainsKey(HttpMethod.Post)); // CreateItem
@@ -171,8 +171,8 @@ public async Task Document_SerializesToValidOpenApi31Json()
Assert.StartsWith("3.1", root.GetProperty("openapi").GetString());
var paths = root.GetProperty("paths");
- Assert.True(paths.TryGetProperty("/api/items", out _));
- Assert.True(paths.TryGetProperty("/api/items/{id}", out _));
+ Assert.True(paths.TryGetProperty("/items", out _));
+ Assert.True(paths.TryGetProperty("/items/{id}", out _));
var schemas = root.GetProperty("components").GetProperty("schemas");
Assert.True(schemas.TryGetProperty(nameof(Item), out _));
@@ -183,7 +183,7 @@ public async Task Document_GetItemById_Has404ProblemDetailsResponse()
{
var document = await BuildSampleDocumentAsync();
- var itemById = Assert.IsType(document.Paths!["/api/items/{id}"]);
+ var itemById = Assert.IsType(document.Paths!["/items/{id}"]);
var getItem = itemById.Operations![HttpMethod.Get];
var response = getItem.Responses!["404"];
@@ -199,7 +199,7 @@ public async Task Document_SearchItems_HasRequiredNameQueryParam()
{
var document = await BuildSampleDocumentAsync();
- var search = Assert.IsType(document.Paths!["/api/items/search"]);
+ var search = Assert.IsType(document.Paths!["/items/search"]);
var operation = search.Operations![HttpMethod.Get];
var name = operation.Parameters!.Single(p => p.Name == "name");
@@ -212,7 +212,7 @@ public async Task Document_SearchItems_Has400ValidationProblemAnd200List()
{
var document = await BuildSampleDocumentAsync();
- var search = Assert.IsType(document.Paths!["/api/items/search"]);
+ var search = Assert.IsType(document.Paths!["/items/search"]);
var operation = search.Operations![HttpMethod.Get];
// 400 -> HttpValidationProblemDetails served as application/problem+json.
@@ -243,7 +243,7 @@ public async Task Document_CreateItem_Has201LocationResponseHeader()
{
var document = await BuildSampleDocumentAsync();
- var items = Assert.IsType(document.Paths!["/api/items"]);
+ var items = Assert.IsType(document.Paths!["/items"]);
var create = items.Operations![HttpMethod.Post];
var header = Assert.IsType(create.Responses!["201"].Headers!["Location"]);
@@ -259,7 +259,7 @@ public async Task Document_ListItems_Has200RateLimitResponseHeader()
{
var document = await BuildSampleDocumentAsync();
- var items = Assert.IsType(document.Paths!["/api/items"]);
+ var items = Assert.IsType(document.Paths!["/items"]);
var list = items.Operations![HttpMethod.Get];
var header = Assert.IsType(list.Responses!["200"].Headers!["X-RateLimit-Remaining"]);
@@ -286,7 +286,7 @@ public async Task Document_ViaAddOpenApi_ResolvesProviderFromDi()
var document = await provider.GetDocumentAsync(TestContext.Current.CancellationToken);
Assert.Equal("DI Sample API", document.Info!.Title);
- Assert.True(document.Paths!.ContainsKey("/api/items"));
+ Assert.True(document.Paths!.ContainsKey("/items"));
}
[Fact]
@@ -294,7 +294,7 @@ public async Task Document_TestAssemblyHeaderSetFunction_EmitsRequestAndResponse
{
var document = await BuildHeaderSetFixtureDocumentAsync();
- var pathItem = Assert.IsType(document.Paths!["/api/header-set-docs"]);
+ var pathItem = Assert.IsType(document.Paths!["/header-set-docs"]);
var operation = pathItem.Operations![HttpMethod.Get];
var tenant = operation.Parameters!.Single(p => p.Name == "X-Tenant-Id");
@@ -316,7 +316,7 @@ public async Task Document_GenericHeaderAttributeFunction_EmitsGenericRequestAnd
{
var document = await BuildHeaderSetFixtureDocumentAsync();
- var pathItem = Assert.IsType(document.Paths!["/api/header-set-docs/generic"]);
+ var pathItem = Assert.IsType(document.Paths!["/header-set-docs/generic"]);
var operation = pathItem.Operations![HttpMethod.Get];
var correlation = Assert.IsType(operation.Parameters!.Single(p => p.Name == "X-Correlation-Id"));
@@ -347,7 +347,7 @@ public async Task Document_MalformedHeaderSetFunction_DoesNotBlockOtherDocumente
{
var document = await BuildHeaderSetFixtureDocumentAsync();
- Assert.True(document.Paths!.ContainsKey("/api/header-set-docs"));
- Assert.False(document.Paths.ContainsKey("/api/header-set-docs/malformed"));
+ Assert.True(document.Paths!.ContainsKey("/header-set-docs"));
+ Assert.False(document.Paths.ContainsKey("/header-set-docs/malformed"));
}
}
diff --git a/tests/AzureExtensions.Functions.OpenApi.Tests/ServerBasePathTests.cs b/tests/AzureExtensions.Functions.OpenApi.Tests/ServerBasePathTests.cs
new file mode 100644
index 0000000..e37ab1b
--- /dev/null
+++ b/tests/AzureExtensions.Functions.OpenApi.Tests/ServerBasePathTests.cs
@@ -0,0 +1,170 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi;
+using SampleFunctionApp.Models;
+using Xunit;
+
+namespace AzureExtensions.Functions.OpenApi.Tests;
+
+///
+/// Tests that the base path (the Functions routePrefix) is advertised via the document's
+/// servers entry — inferred from the request by default — rather than repeated on every path
+/// key. Exercises the full serve path () against an
+/// in-memory ; no Functions host, no network.
+///
+public sealed class ServerBasePathTests
+{
+ private static OpenApiHttpFunctions CreateFunctions(Action? configure = null)
+ {
+ var options = new OpenApiOptions { Title = "Server Base API", Version = "1.0.0" };
+ options.DocumentAssemblies.Add(typeof(Item).Assembly);
+ configure?.Invoke(options);
+ var wrapped = Options.Create(options);
+ var provider = new OpenApiDocumentProvider(wrapped);
+ return new OpenApiHttpFunctions(provider, wrapped, NullLogger.Instance);
+ }
+
+ private static HttpRequest CreateHttpRequest(
+ string? scheme = "https",
+ string? host = "example.com",
+ string pathBase = "",
+ string? forwardedHost = null,
+ string? forwardedProto = null)
+ {
+ var context = new DefaultHttpContext();
+ if (scheme is not null)
+ {
+ context.Request.Scheme = scheme;
+ }
+
+ if (!string.IsNullOrEmpty(host))
+ {
+ context.Request.Host = new HostString(host);
+ }
+
+ if (!string.IsNullOrEmpty(pathBase))
+ {
+ context.Request.PathBase = new PathString(pathBase);
+ }
+
+ if (forwardedHost is not null)
+ {
+ context.Request.Headers["X-Forwarded-Host"] = forwardedHost;
+ }
+
+ if (forwardedProto is not null)
+ {
+ context.Request.Headers["X-Forwarded-Proto"] = forwardedProto;
+ }
+
+ return context.Request;
+ }
+
+ private static async Task ExecuteJsonAsync(IResult result)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ using var serviceProvider = services.BuildServiceProvider();
+
+ var context = new DefaultHttpContext { RequestServices = serviceProvider };
+ var responseBody = new MemoryStream();
+ context.Response.Body = responseBody;
+
+ await result.ExecuteAsync(context);
+
+ responseBody.Position = 0;
+ var body = await new StreamReader(responseBody, Encoding.UTF8).ReadToEndAsync();
+ return JsonDocument.Parse(body);
+ }
+
+ private static string FirstServerUrl(JsonDocument doc) =>
+ doc.RootElement.GetProperty("servers")[0].GetProperty("url").GetString()!;
+
+ [Fact]
+ public async Task Servers_InferredFromRequestHost_AndPathsAreRelative()
+ {
+ var functions = CreateFunctions();
+
+ using var doc = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(scheme: "https", host: "example.com")));
+
+ Assert.Equal("https://example.com/api", FirstServerUrl(doc));
+
+ var paths = doc.RootElement.GetProperty("paths");
+ Assert.True(paths.TryGetProperty("/items", out _));
+ Assert.False(paths.TryGetProperty("/api/items", out _));
+ }
+
+ [Fact]
+ public async Task Servers_HonorForwardedHeadersAndPathBase()
+ {
+ var functions = CreateFunctions();
+
+ using var doc = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(
+ scheme: "http",
+ host: "internal:8080",
+ pathBase: "/base",
+ forwardedHost: "api.contoso.com",
+ forwardedProto: "https")));
+
+ Assert.Equal("https://api.contoso.com/base/api", FirstServerUrl(doc));
+ }
+
+ [Fact]
+ public async Task Servers_FallBackToRelative_WhenHostUnresolved()
+ {
+ var functions = CreateFunctions();
+
+ using var doc = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(scheme: null, host: null)));
+
+ Assert.Equal("/api", FirstServerUrl(doc));
+ }
+
+ [Fact]
+ public async Task Servers_EmptyRoutePrefix_YieldsHostOnlyServer()
+ {
+ var functions = CreateFunctions(o => o.RoutePrefix = string.Empty);
+
+ using var doc = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(scheme: "https", host: "example.com")));
+
+ Assert.Equal("https://example.com", FirstServerUrl(doc));
+ }
+
+ [Fact]
+ public async Task Servers_ExplicitOption_OverridesRequestInference()
+ {
+ var functions = CreateFunctions(o =>
+ o.Servers.Add(new OpenApiServer { Url = "https://fixed.example/api" }));
+
+ using var doc = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(scheme: "https", host: "example.com")));
+
+ Assert.Equal("https://fixed.example/api", FirstServerUrl(doc));
+ }
+
+ [Fact]
+ public async Task Servers_DifferentHosts_ProduceDifferentServers_WithoutMutatingCachedPaths()
+ {
+ // Same functions instance -> shared, cached document. Per-request servers must not leak
+ // across requests, and the cached paths must remain intact.
+ var functions = CreateFunctions();
+
+ using var first = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(host: "first.example")));
+ using var second = await ExecuteJsonAsync(
+ await functions.GetOpenApiJson(CreateHttpRequest(host: "second.example")));
+
+ Assert.Equal("https://first.example/api", FirstServerUrl(first));
+ Assert.Equal("https://second.example/api", FirstServerUrl(second));
+
+ Assert.True(first.RootElement.GetProperty("paths").TryGetProperty("/items", out _));
+ Assert.True(second.RootElement.GetProperty("paths").TryGetProperty("/items", out _));
+ }
+}