Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ internal sealed class FunctionEndpointDiscovery
/// The assemblies to scan. When empty, a default set is resolved via
/// <see cref="GetDefaultAssemblies"/>.
/// </param>
/// <param name="routePrefix">
/// The host route prefix (for example <c>api</c>). May be empty to serve from the root.
/// </param>
/// <returns>The discovered endpoints, in discovery order.</returns>
public IReadOnlyList<DiscoveredEndpoint> Discover(IEnumerable<Assembly> assemblies, string routePrefix)
/// <remarks>
/// Emitted path keys are relative to the host route prefix (for example <c>/widgets</c>, not
/// <c>/api/widgets</c>). The prefix is a global host setting and is advertised via the
/// document's <c>servers</c> base URL rather than repeated on every path.
/// </remarks>
public IReadOnlyList<DiscoveredEndpoint> Discover(IEnumerable<Assembly> assemblies)
{
var candidates = (assemblies ?? []).Where(static a => a is not null).Distinct().ToList();
if (candidates.Count == 0)
Expand All @@ -78,7 +80,7 @@ public IReadOnlyList<DiscoveredEndpoint> Discover(IEnumerable<Assembly> assembli
{
foreach (var method in GetMethodsSafe(type))
{
var endpoint = TryDiscover(method, routePrefix);
var endpoint = TryDiscover(method);
if (endpoint is not null)
{
results.Add(endpoint);
Expand Down Expand Up @@ -121,7 +123,7 @@ public IEnumerable<Assembly> GetDefaultAssemblies()
return result;
}

private static DiscoveredEndpoint? TryDiscover(MethodInfo method, string routePrefix)
private static DiscoveredEndpoint? TryDiscover(MethodInfo method)
{
try
{
Expand All @@ -147,7 +149,7 @@ public IEnumerable<Assembly> 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);
Expand Down Expand Up @@ -193,12 +195,12 @@ private static IReadOnlyList<string> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 64 additions & 5 deletions src/AzureExtensions.Functions.OpenApi/OpenApiHttpFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public async Task<IResult> 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);
}
Expand All @@ -73,7 +73,7 @@ public async Task<IResult> 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);
}
Expand Down Expand Up @@ -114,16 +114,75 @@ public IResult GetSwaggerUi(
/// </summary>
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}";
}

/// <summary>
/// Produces the per-request <c>servers</c> list for the document. When
/// <see cref="OpenApiOptions.Servers"/> 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 <see cref="OpenApiOptions.RoutePrefix"/>. When the host cannot be
/// resolved the URL falls back to a relative base path so the document is still valid.
/// </summary>
private IList<OpenApiServer> 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<OpenApiServer> { new() { Url = url } };
}

/// <summary>
/// Returns a per-request shallow copy of the cached document with its <c>servers</c> 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.
/// </summary>
private OpenApiDocument WithServers(OpenApiDocument document, HttpRequest request) =>
new(document) { Servers = BuildServers(request) };

/// <summary>
/// Resolves the request's public-facing base URL as <c>{scheme}://{host}</c> (plus the request
/// path base when present), honoring <c>X-Forwarded-Proto</c> / <c>X-Forwarded-Host</c>. Returns
/// <see langword="null"/> when no host can be determined.
/// </summary>
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}";
}

/// <summary>
Expand Down
14 changes: 13 additions & 1 deletion src/AzureExtensions.Functions.OpenApi/OpenApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,22 @@ public sealed class OpenApiOptions

/// <summary>
/// The route prefix the Functions host uses for HTTP triggers. Defaults to <c>api</c>,
/// 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 <c>servers</c> entry rather than repeated on every path key. It also
/// drives the effective URLs of the served document/Swagger UI endpoints.
/// </summary>
public string RoutePrefix { get; set; } = "api";

/// <summary>
/// Optional explicit <c>servers</c> for the generated document. When empty (the default), a
/// single server is inferred from the incoming request (scheme/host, honoring
/// <c>X-Forwarded-Proto</c> / <c>X-Forwarded-Host</c> and the request path base) combined with
/// <see cref="RoutePrefix"/>, 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.
/// </summary>
public IList<OpenApiServer> Servers { get; } = new List<OpenApiServer>();

/// <summary>
/// The route (relative to <see cref="RoutePrefix"/>) that serves the JSON document.
/// </summary>
Expand Down
30 changes: 7 additions & 23 deletions tests/AzureExtensions.Functions.OpenApi.Tests/DiscoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ public void TimerThing() { }
/// </summary>
public sealed class DiscoveryTests
{
private static IReadOnlyList<DiscoveredEndpoint> DiscoverFakes(string routePrefix = "api")
private static IReadOnlyList<DiscoveredEndpoint> 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<DiscoveredEndpoint> endpoints, string function) =>
Expand All @@ -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]
Expand All @@ -100,15 +100,15 @@ 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]
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);
}

Expand All @@ -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()
{
Expand Down
Loading
Loading