API catalog
Bielu.AspNetCore.ApiCatalog serves an RFC 9727 API catalog: a single
well-known URL, /.well-known/api-catalog, that lists every API description the app serves. A client, an API
gateway or a crawler can then find the AsyncAPI, OpenAPI and Arazzo documents without knowing their routes.
dotnet add package Bielu.AspNetCore.ApiCatalog
builder.Services.AddOpenApi();
builder.Services.AddAsyncApi();
builder.Services.AddArazzo(options => { /* workflows */ });
var app = builder.Build();
app.MapOpenApi();
app.MapAsyncApi();
app.MapArazzo();
app.MapApiCatalog();
What the catalog lists
The catalog is an RFC 9264 Linkset, served as
application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727". It has two link contexts:
- the catalog itself, whose
itemlinks are the APIs: this app (itsApiUrl, the base URL by default), everyAddItem(...)entry, then every endpoint marked withWithApiCatalogItem(), such as an MCP server; - this app's API, with one
service-desclink per document and oneservice-doclink perAddServiceDoc(...)entry.
{
"linkset": [
{
"anchor": "https://localhost:5001/.well-known/api-catalog",
"item": [ { "href": "https://localhost:5001/" } ]
},
{
"anchor": "https://localhost:5001/",
"service-desc": [
{ "href": "https://localhost:5001/openapi/v1.json", "type": "application/vnd.oai.openapi+json", "title": "OpenAPI document 'v1'" },
{ "href": "https://localhost:5001/asyncapi/v1.json", "type": "application/vnd.aai.asyncapi+json", "title": "AsyncAPI document 'v1'" },
{ "href": "https://localhost:5001/arazzo/v1.json", "type": "application/vnd.oai.workflows+json", "title": "Arazzo document 'v1'" }
]
}
]
}
Documents are found from the app's own endpoints, in the order they were mapped:
| Endpoint | Media type | Documents |
|---|---|---|
MapAsyncApi() |
application/vnd.aai.asyncapi+json (+yaml for a .yaml/.yml route) |
every AddAsyncApi document |
MapArazzo() |
application/vnd.oai.workflows+json (+yaml) |
every AddArazzo document |
MapOpenApi() |
application/vnd.oai.openapi+json (+yaml) |
every AddOpenApi document |
MapAsyncApi() and MapArazzo() mark their endpoints with ApiDescriptionDocumentMetadata. MapOpenApi() leaves no
marker, so its endpoint is recognized by its route: if you give it a route other than /openapi/{documentName}.json,
set the same route on ApiCatalogOptions.OpenApiRoutePattern, or set it to null to leave OpenAPI documents out.
To list documents from an endpoint of your own, add the same metadata to it. The kind has to match the Kind of the
IBuildTimeDocumentSource that owns those documents:
app.MapGet("/workflows/{documentName}.json", handler)
.WithMetadata(new ApiDescriptionDocumentMetadata("Workflow", "application/json"));
Endpoint APIs
An API served from an endpoint rather than described by a document, such as the app's
MCP server, is listed as an item of its own with WithApiCatalogItem():
app.MapMcp("/mcp").WithApiCatalogItem("MCP server");
app.MapApiCatalog();
"item": [
{ "href": "https://localhost:5001/" },
{ "href": "https://localhost:5001/mcp", "title": "MCP server" }
]
Endpoints sharing a route, such as the POST, GET and DELETE endpoints MapMcp() maps, are listed once. A route
with parameters cannot be linked to, so it is left out. The MCP SDK leaves no marker of its own on its endpoints, which
is why the catalog does not find an MCP server unless it is marked.
Options
builder.Services.AddApiCatalog(options => options
.AddServiceDoc("/scalar", "API reference")
.AddItem("https://payments.example.com/.well-known/api-catalog", "Payments"));
| Member | Effect |
|---|---|
AddServiceDoc(href, title) |
Adds a service-doc link (RFC 8631) to human-readable documentation such as the Scalar page. |
AddItem(href, title) |
Adds an item link: another API, or another catalog to nest. |
OpenApiRoutePattern |
The route MapOpenApi() was given. null leaves OpenAPI documents out. |
ApiUrl |
Where this app's API lives: its item link, and the anchor of its service-desc/service-doc links. Defaults to /; set it (for example to /api) when the app root serves nothing. |
AddApiCatalog() is optional; MapApiCatalog() works with the defaults.
Paths such as /scalar are resolved against the app's base URL. Every link in the catalog is absolute, built from the
request's scheme, host and path base, so behind a reverse proxy apply the forwarded headers
(app.UseForwardedHeaders()) before the catalog endpoint.
HTTP behavior
GETreturns the catalog.HEADreturns the same headers without a body, as RFC 9727 requires.- Both carry
Link: <…/.well-known/api-catalog>; rel="api-catalog".
MapApiCatalog() returns the endpoint's convention builder, so you can restrict access to an internal catalog as the
RFC recommends:
app.MapApiCatalog().RequireAuthorization("internal");