From 7da5d26fbbf9af8671ff5b4c86b8e3b289c35d99 Mon Sep 17 00:00:00 2001 From: Espen Berglund Date: Tue, 1 Sep 2026 22:15:29 +0200 Subject: [PATCH] Advertise route prefix via servers instead of on every path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path keys are now relative to the API base (e.g. `/items`, not `/api/items`). The Functions route prefix is a global base path, so it is advertised once via the document's top-level `servers` array — the idiomatic OpenAPI representation of a shared base URL (effective URL = servers[].url + path key). By default the server URL is inferred from the incoming request (scheme, host, path base, honouring X-Forwarded-Proto / X-Forwarded-Host), with a relative fallback when the host is unresolved. The new OpenApiOptions.Servers lets callers advertise fixed URLs (e.g. an API gateway) verbatim, skipping request inference. The per-request servers are applied to a shallow copy of the cached document so the shared singleton is never mutated across differing hosts. BREAKING (pre-1.0): consumers relying on `/api` in the generated path keys must update. Recommend releasing as 0.2.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 13 ++ README.md | 29 ++- .../Discovery/FunctionEndpointDiscovery.cs | 22 +-- .../OpenApiDocumentProvider.cs | 2 +- .../OpenApiHttpFunctions.cs | 69 ++++++- .../OpenApiOptions.cs | 14 +- .../DiscoveryTests.cs | 30 +--- .../DocumentGenerationTests.cs | 32 ++-- .../ServerBasePathTests.cs | 170 ++++++++++++++++++ 9 files changed, 324 insertions(+), 57 deletions(-) create mode 100644 tests/AzureExtensions.Functions.OpenApi.Tests/ServerBasePathTests.cs 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 _)); + } +}