JsonSchema.Net.Api can describe an ASP.NET Core application in OpenAPI 3.1. The OpenAPI Description is assembled at compile time from your controllers and minimal-API route registrations, served as JSON or YAML, and presented on a built-in reference page.
The schemas in the OpenAPI Description are the same ones used for request validation.
OpenAPI support was added in JsonSchema.Net.Api v1.2.0.
Getting started
Add the OpenAPI services in Program.cs:
1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapControllers();
app.Run();
With no further configuration, the application:
- serves the OpenAPI Description at
/openapi.jsonand/openapi.yaml - serves an interactive reference page at
/openapi/reference - registers request validation (see Validation registration)
The OpenAPI Description’s title and version are seeded from the entry assembly’s name and version. Everything else is discovered from the API surface.
Configuration
AddOpenApi() accepts an optional delegate that receives an OpenApiOptions:
1
2
3
4
5
6
7
builder.Services.AddOpenApi(c =>
{
c.Document.Info.Title = "Pet Store";
c.Document.Info.Description = "The pet store API.";
c.DocumentPath = "/openapi";
c.InteractivePath = "/openapi/reference";
});
A OpenAPI Description is served only when DocumentPath has a value, the reference page only when InteractivePath does, and a file is written only when FileOutputPath does. Set a path to null to turn that piece off.
| Option | Default | Purpose |
|---|---|---|
Name | null | The OpenAPI Description’s name, read-only; null for the default OpenAPI Description (see Publishing several OpenAPI Descriptions) |
Document | assembled from the API | The OpenAPI Description itself, exposed for editing |
DocumentPath | /openapi | Where the OpenAPI Description is served, without an extension |
DocumentFormats | Json \| Yaml | Which extensions resolve at DocumentPath |
InteractivePath | /openapi/reference | Where the reference page is served |
StylesheetUrl | null | A stylesheet applied to the reference page |
FileOutputPath | null | A file path the OpenAPI Description is written to at startup, without an extension |
AddValidation | true | Whether request validation is registered alongside the OpenAPI Description |
Editing the OpenAPI Description
Document is the assembled OpenApiDocument. It is built before your delegate runs, so anything the generator cannot infer is added by editing it directly: contact details, servers, security schemes, license and terms of service.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
builder.Services.AddOpenApi(c =>
{
c.Document.Info.Title = "Pet Store";
c.Document.Info.Version = "2.4.0";
c.Document.Info.Contact = new ContactInfo
{
Name = "API Support",
Email = "support@example.com"
};
c.Document.Servers =
[
new Server("https://api.example.com") { Description = "Production" },
new Server("https://staging.example.com") { Description = "Staging" }
];
c.Document.Components ??= new ComponentCollection();
c.Document.Components.SecuritySchemes = new Dictionary<string, SecurityScheme>
{
["bearer"] = new SecurityScheme("http")
{
Scheme = "bearer",
BearerFormat = "JWT"
}
};
});
The full OpenAPI 3.1 model is available under Document, so any part of the specification can be expressed. The path items and operations are reachable through Document.Paths if you need to adjust a discovered operation beyond what its metadata supplies.
A later
AddOpenApi()call replaces an earlier one, so a test host or an environment-specific setup can reconfigure the OpenAPI Description.
Document path and formats
DocumentPath carries no extension. The extensions that resolve are decided by DocumentFormats:
DocumentFormats | URLs served |
|---|---|
Json \| Yaml (default) | /openapi.json, /openapi.yaml, /openapi.yml |
Json | /openapi.json |
Yaml | /openapi.yaml, /openapi.yml |
Requesting the bare path /openapi returns 404.
To build the OpenAPI Description without serving it, set DocumentPath to null. This is useful when you only want a file written, or when the OpenAPI Description should be published only outside production:
1
2
3
4
5
6
7
8
builder.Services.AddOpenApi(c =>
{
if (!builder.Environment.IsDevelopment())
{
c.DocumentPath = null;
c.InteractivePath = null;
}
});
Reference page
InteractivePath is where the reference page is served. The page reads the OpenAPI Description over HTTP, so it requires that DocumentPath has a value and DocumentFormats includes Json.
One page covers every OpenAPI Description the application publishes, so InteractivePath is configured on the AddOpenApi() call that takes no name. Setting it on a named OpenAPI Description is an error.
If any of these constraints is violated, AddOpenApi() throws InvalidOperationException at startup.
Set InteractivePath to null to serve the OpenAPI Description without a page.
Restyling the page
StylesheetUrl names a stylesheet that is loaded after the page’s built-in styles, so its rules win where they overlap.
The built-in styles define their palette as custom properties on :root, and every element carries a class prefixed oa-. A stylesheet can restyle the page by redefining the properties alone:
1
2
3
4
:root {
--oa-accent: #b3341e;
--oa-font: "Inter", sans-serif;
}
or by targeting classes directly:
1
2
3
.oa-bar {
border-bottom: 2px solid var(--oa-accent);
}
The page also defines a dark palette, applied when the browser prefers a dark color scheme or when the reader selects it with the page’s theme toggle. Redefine the properties under :root[data-theme="dark"] to restyle that as well.
Writing the OpenAPI Description to disk
FileOutputPath names a file the OpenAPI Description is written to when the application starts. Like DocumentPath, it carries no extension; one file is written per format in DocumentFormats.
1
2
3
4
builder.Services.AddOpenApi(c =>
{
c.FileOutputPath = "docs/openapi";
});
With the default formats, this writes docs/openapi.json and docs/openapi.yaml. The directory is created if it does not exist.
Writing the OpenAPI Description to disk lets it be committed alongside the code, diffed in code review, and handed to client-generation tooling without running the application. The served and written OpenAPI Descriptions are rendered from the same document, so they cannot drift.
Validation registration
AddValidation defaults to true, so AddOpenApi() registers request validation on its own. The OpenAPI Description states that a validated endpoint answers malformed input with application/problem+json, and registering validation here keeps that promise true without a second call.
Registration is idempotent, and an explicit AddJsonSchemaValidation(...) still wins on configuration, so both calls together are fine:
1
2
3
4
5
6
7
builder.Services.AddControllers()
.AddJsonSchemaValidation(converter =>
{
converter.EvaluationOptions.RequireFormatValidation = true;
});
builder.Services.AddOpenApi();
If you previously called
AddJsonSchemaValidation()yourself, nothing changes. If you did not, addingAddOpenApi()turns validation on. SetAddValidationto false to describe an API without validating requests.
Registering validation also registers the enum converter matching the enum format the generated schemas describe, unless the application has already added a JsonStringEnumConverter. See Enumerations in the validation docs.
Publishing several OpenAPI Descriptions
An application can publish more than one OpenAPI Description, split by controller. Place [OpenApiDocument] on a controller to put its endpoints in a named OpenAPI Description:
1
2
3
4
5
6
7
[OpenApiDocument("admin")]
[ApiController]
[Route("api/admin")]
public class AdminController : ControllerBase
{
// ...
}
The attribute takes any number of names, so a controller that serves two audiences can name both and appears in each. Three rules govern where a controller lands:
- A controller without the attribute appears in the default OpenAPI Description.
- A controller with the attribute appears only in the OpenAPI Descriptions it names, never in the default one.
- Adding the attribute to one controller does not move any other controller.
Minimal APIs always land in the default OpenAPI Description.
Each named OpenAPI Description is registered with its own AddOpenApi() call, passing the name:
1
2
3
4
5
builder.Services.AddOpenApi(); // default
builder.Services.AddOpenApi("admin", c => // named
{
c.Document.Info.Title = "Admin API";
});
A named OpenAPI Description is served at /openapi/{name}.json and /openapi/{name}.yaml by default, so several can be published without configuring paths, and each remains independently addressable for client generators and CI. A named OpenAPI Description carries no reference page of its own; the one page, configured on the unnamed call, covers all of them with a selector in its header.
Each OpenAPI Description carries only the component schemas its own operations reach. Splitting the API does not give every OpenAPI Description every schema.
If two OpenAPI Descriptions share a DocumentPath, InteractivePath, or FileOutputPath, AddOpenApi() throws InvalidOperationException at startup. Calling AddOpenApi() a second time with the same name replaces the earlier registration.
Only the default OpenAPI Description can be resolved from dependency injection as a bare
OpenApiDocument. Named OpenAPI Descriptions are reachable through theirOpenApiOptions.
How the OpenAPI Description is assembled
The OpenAPI Description is produced at compile time by a source generator. There is no runtime reflection over the API surface, so this works with Native AOT.
The generator reads two kinds of declaration:
- Controllers — public methods carrying
[HttpGet],[HttpPost],[HttpPut],[HttpDelete],[HttpPatch],[HttpHead], or[HttpOptions]. The route is composed from the class’s[Route]prefix and the method attribute’s template, with[controller]and[action]substituted. - Minimal APIs —
MapGet,MapPost,MapPut,MapDelete, andMapPatchcalls with a constant route template, including those made on aMapGroup(nested groups compose their prefixes, and fluent calls such as.WithTags()chained onto the group are seen through)..Produces<T>()declares a response.
Route templates are written in their OpenAPI form. ASP.NET constraints, defaults, optional markers, and catch-alls are stripped, so /users/{id:int} is described as /users/{id}.
For each operation, the generator determines:
- Operation ID — for a controller action, the controller name without its
Controllersuffix, an underscore, and the action name:Users_GetByIdforUsersController.GetById. OpenAPI requires operation IDs to be unique across a OpenAPI Description, and action names alone collide as soon as two controllers share one. A minimal API has an operation ID only when.WithName()is chained onto its registration. - Request body — a
[FromBody]parameter, or any complex-typed parameter on either style, mirroring ASP.NET’s own binding inference. The body is documented as requiredapplication/json. Injected services, including interfaces and abstract types and anything marked[FromServices], are never described. - Parameters — the remaining parameters. A parameter named in the route template is a path parameter and is always required; any other is a query parameter.
[FromRoute],[FromQuery], and[FromHeader]override this on both controllers and minimal APIs. A parameter is optional when it has a default value or its type admits null (Nullable<T>, or a reference type annotated with?). - Responses —
[ProducesResponseType]attributes or.Produces<T>()calls when present. Otherwise a 200 response whose payload type is read from anActionResult<T>return type, from a concrete return type, or from the argument of aResults.Ok(...)call in a minimal-API handler body.
Summaries, descriptions, and tags
Operations carry summaries, descriptions, tags, and descriptions for their parameters, request body, and responses. These come from two sources.
ASP.NET’s own metadata is read first. On controllers, [EndpointSummary], [EndpointDescription], and [Tags] on an action, plus [Tags] on the controller class, which applies to every action in it. On minimal APIs, .WithSummary(), .WithDescription(), and .WithTags() chained onto the registration. These are the framework’s attributes from Microsoft.AspNetCore.Http, so an application migrating from another OpenAPI generator already has them.
XML documentation comments fill whatever the attributes left unset. The comment on a controller action is read, as is the comment on the method a minimal-API method group points at. A lambda has no documentation comment.
| Element | Where it lands |
|---|---|
<summary> | the operation’s summary |
<remarks> | the operation’s description |
<param name="x"> | that parameter’s description, or the request body’s when x is the body parameter |
<returns> | the 200 response’s description |
<response code="404"> | that response’s description; the response is added if the action does not declare it |
Inline elements are flattened to text. <see cref="Foo"/> becomes Foo, <paramref name="id"/> becomes id, <c> and <code> keep their contents, and <para> separates paragraphs with a blank line. Tags come only from attributes and fluent calls.
The compiler only parses documentation comments when the project generates a documentation file. Without this setting, the OpenAPI Description silently carries no summaries or descriptions.
1 2 3 4 <PropertyGroup> <GenerateDocumentationFile>true</GenerateDocumentationFile> <NoWarn>$(NoWarn);CS1591</NoWarn> </PropertyGroup>
CS1591warns on every public member without a comment, which is rarely wanted on an API project.
Schemas
Schemas come from types decorated with [GenerateJsonSchema]. Each such type lands in components/schemas once, keyed by its type name, and every body and response that uses it carries a $ref. The schemas are the same ones the source generator produces for validation, minus $id and $schema, which an OpenAPI-internal schema does not carry.
Primitive parameter and response types (string, int, bool, Guid, DateTime, and so on) are described inline. A body or response type without [GenerateJsonSchema] is documented with no schema.
Source-generated schemas describe an enumeration the way it is serialized: by name, as an integer, or either, following the enum’s [JsonConverter] or the JsonSchemaDefaultEnumFormat build property. An enum type is a component schema like any other and is referenced with $ref; only where a property’s serialization differs from the default, through a [JsonConverter] on that property, is the enum described inline for that property. See Enumerations in the source generation docs. Validation registration configures the serializer to match.
Class libraries
Controllers and endpoints declared in a class library appear in the host’s OpenAPI Description without that library referencing the host or registering anything. A solution split into feature libraries needs no wiring beyond the project references it already has.
The validation response
Every endpoint whose body type carries [GenerateJsonSchema] is validated by the middleware before the handler runs. When the body does not satisfy its schema, the middleware answers with 400 and a Problem Details body, and the handler never sees the request.
The OpenAPI Description documents this. Each validated operation gets a 400 response returning application/problem+json:
1
2
3
4
5
6
7
8
9
10
"400": {
"description": "The request body did not satisfy its schema.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
This response is added automatically to every validated operation. If an operation already declares a 400 response, that declaration is kept.
Example
A controller with one validated endpoint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Models/CreateUserRequest.cs
[GenerateJsonSchema(PropertyNaming = NamingConvention.CamelCase)]
[AdditionalProperties(false)]
public class CreateUserRequest
{
[Required]
[MinLength(3)]
[MaxLength(50)]
public string Username { get; set; }
[Required]
[Minimum(18)]
public int Age { get; set; }
}
// Models/User.cs
[GenerateJsonSchema(PropertyNaming = NamingConvention.CamelCase)]
public class User
{
public Guid Id { get; set; }
public string Username { get; set; }
}
// Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
[Tags("Users")]
public class UsersController : ControllerBase
{
/// <summary>
/// Creates a user.
/// </summary>
/// <param name="request">The user to create.</param>
/// <returns>The created user.</returns>
[HttpPost]
public ActionResult<User> CreateUser([FromBody] CreateUserRequest request)
{
var user = _userService.CreateUser(request);
return user;
}
}
produces this OpenAPI Description:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
{
"openapi": "3.1.1",
"info": {
"title": "MyApi",
"version": "1.0.0"
},
"paths": {
"/api/Users": {
"post": {
"tags": ["Users"],
"summary": "Creates a user.",
"operationId": "Users_CreateUser",
"requestBody": {
"description": "The user to create.",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateUserRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "The created user.",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/User" }
}
}
},
"400": {
"description": "The request body did not satisfy its schema.",
"content": {
"application/problem+json": {
"schema": { "$ref": "#/components/schemas/ValidationProblemDetails" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateUserRequest": {
"type": "object",
"properties": {
"username": { "type": "string", "minLength": 3, "maxLength": 50 },
"age": { "type": "integer", "minimum": 18 }
},
"required": ["username", "age"],
"additionalProperties": false
},
"User": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"username": { "type": "string" }
}
},
"ValidationProblemDetails": {
"type": "object",
"properties": {
"type": { "type": "string", "const": "https://json-everything.net/errors/validation" },
"title": { "type": "string" },
"status": { "type": "integer" },
"detail": { "type": "string" },
"errors": { "type": "object" }
}
}
}
}
}
The same endpoint as a minimal API is discovered the same way, with the metadata supplied by fluent calls:
1
2
3
4
5
6
7
8
app.MapPost("/api/users", (CreateUserRequest request, IUserService users) =>
{
var user = users.CreateUser(request);
return Results.Ok(user);
})
.WithName("Users_CreateUser")
.WithSummary("Creates a user.")
.WithTags("Users");
The reference page
The page at InteractivePath presents the OpenAPI Description for a reader and lets them exercise the API from the browser. It is a single page with no external dependencies. It fetches each OpenAPI Description from its DocumentPath.
The page has three regions:
- Navigation lists every operation by method and path. Clicking one scrolls the documentation to it and opens it in the request console.
- Documentation shows each operation’s summary, description, tags, parameters, request body, and responses. Body schemas are rendered as property lists showing type, whether the property is required,
constvalues, descriptions, and nested properties. A schema withadditionalProperties: falsesays so. Clicking an operation’s heading opens it in the request console. - Request console shows the selected operation: a request in cURL, JavaScript, C#, Python, or Go with a copy button; inputs for each parameter and a body editor prefilled with an example built from the schema; and the full request body schema. Sending the request shows the status, elapsed time, and response body.
The console changes only when the reader selects an operation. Scrolling the documentation does not change it, and each operation keeps its edited inputs and its last response for as long as the page is open. The navigation and console panes can be resized by dragging their edges; the widths are remembered by the browser.
The header carries an OpenAPI Description selector when the application publishes more than one (labeled by each OpenAPI Description’s title), a server selector populated from Document.Servers, a theme toggle that persists the reader’s choice, and an authentication panel when Document.Components.SecuritySchemes is set. The panel supports HTTP bearer, HTTP basic, and API key schemes (header or query). Credentials entered there are held in page memory only and applied to the sample code and to sent requests.