Start Debugging

Как пометить ответ действия контроллера как допускающий null во встроенном документе OpenAPI в ASP.NET Core

Microsoft.AspNetCore.OpenApi помечает ответ как допускающий null, только когда действие возвращает голый T? (а в .NET 10 не помечает даже Task<T?>). ActionResult<T?> и IActionResult теряют аннотацию и в .NET 10, и в .NET 11 RC2. Здесь разобрано, почему так происходит, показан проверенный трансформер операций, который это исправляет, и ловушка с 204 No Content, из-за которой спецификация лжёт, пока вы не измените форматтер MVC.

Короткий ответ: в .NET 10 (Microsoft.AspNetCore.OpenApi 10.0.13) и .NET 11 RC2 (11.0.0-rc.2.26504.105) встроенный генератор оборачивает схему ответа в oneOf: [{ "type": "null" }, ...] только тогда, когда сам возвращаемый тип действия допускает null: Person?, а в .NET 11 ещё и Task<Person?>. Внутрь ActionResult<Person?> он не заглядывает, а у IActionResult с [ProducesResponseType<Person>] смотреть вообще не на что, поэтому в обоих случаях схема получается без null. Зарегистрируйте трансформер операций, который читает MethodInfo действия через NullabilityInfoContext, снимает обёртки Task<> и ActionResult<> и сам оборачивает схему ответа 2xx (код ниже, проверено на обеих версиях). Затем решите, что null на самом деле означает на уровне протокола, потому что MVC по умолчанию отправляет для null-результата 204 No Content, а не 200 с null.

Я столкнулся с этим при генерации клиента через Kiota для API на контроллерах: сгенерированный метод для GET /people/{id} возвращал Person без null, хотя действие было объявлено как ActionResult<Person?>. Всё ниже измерено на тестовом приложении с SDK 10.0.302 (среда выполнения 10.0.10) и SDK 11.0.100-rc.2.26504.105: я выгружал /openapi/v1.json и дёргал каждый endpoint через curl.

Что генератор выдаёт для каждой формы возвращаемого значения

В тестовом контроллере по одному действию на каждую форму, все возвращают null, при включённом <Nullable>enable</Nullable>:

// .NET 10 / .NET 11 RC2, C# 14, <Nullable>enable</Nullable>
[ApiController]
[Route("people")]
public class PeopleController : ControllerBase
{
    [HttpGet("plain")]
    public Person? GetPlain() => null;

    [HttpGet("taskplain")]
    public async Task<Person?> GetTaskPlain() { await Task.Yield(); return null; }

    [HttpGet("actionresult")]
    public ActionResult<Person?> GetActionResult() => (Person?)null;

    [HttpGet("task")]
    public async Task<ActionResult<Person?>> GetTask() { await Task.Yield(); return (Person?)null; }

    [HttpGet("attr")]
    [ProducesResponseType<Person>(StatusCodes.Status200OK)]
    public IActionResult GetAttr() => Ok(null);

    [HttpGet("arint")]
    public ActionResult<int?> GetArInt() => (int?)null;
}

Вот схема ответа 200, которую получила каждая форма:

Возвращаемый тип.NET 10.0.13.NET 11 RC2
Person?допускает null (oneOf)допускает null (oneOf)
Task<Person?>не допускает nullдопускает null
ActionResult<Person?>не допускает nullне допускает null
Task<ActionResult<Person?>>не допускает nullне допускает null
IActionResult + [ProducesResponseType<Person>]не допускает nullне допускает null
ActionResult<int?>type: ["null", "integer", "string"], без oneOfдопускает null (oneOf)
Minimal API Person? () => nullдопускает null (oneOf)допускает null (oneOf)

“Допускает null” здесь означает, что схема получилась такой:

"schema": {
  "oneOf": [
    { "type": "null" },
    { "$ref": "#/components/schemas/Person" }
  ]
}

а “не допускает null” означает голый { "$ref": "#/components/schemas/Person" }. Сам компонент Person никогда не помечается как допускающий null. Это сделано намеренно: начиная с .NET 10 RC1 (dotnet/aspnetcore#63301) генератор держит один общий компонент на тип, а допустимость null выражает в месте использования через встроенную обёртку oneOf, поэтому тип, допускающий null в одном endpoint и не допускающий в другом, не раздваивается на два компонента.

Почему ActionResult<T?> теряет аннотацию

Решение принимается в одном внутреннем методе, TypeExtensions.ShouldApplyNullableResponseSchema в src/OpenApi/src/Extensions/TypeExtensions.cs. В теге v10.0.0 он делает следующее:

  1. Берёт MethodInfo действия из ControllerActionDescriptor.
  2. Снимает Task<T> или ValueTask<T> с MethodInfo.ReturnType.
  3. Если этот тип не равен ApiResponseType.Type (тип, который ApiExplorer сообщает для ответа), возвращает false.
  4. Иначе выполняет NullabilityInfoContext.Create(methodInfo.ReturnParameter) и проверяет WriteState == Nullable.

На шаге 3 ActionResult<Person?> и отсеивается. ApiExplorer сообщает тип ответа Person, метод возвращает ActionResult<Person>, типы не равны, на этом всё. До проверки допустимости null дело не доходит. То же с IActionResult и атрибутом: возвращаемый тип IActionResult, тип ответа Person.

Шаг 4 объясняет промах Task<Person?> в .NET 10. Развёрнутый тип совпадает, но проверка допустимости null выполняется для исходного возвращаемого параметра, то есть для Task<Person?>. Сам Task не допускает null, поэтому WriteState равен NotNull.

dotnet/aspnetcore#67661, влитый в июле 2026 и вошедший в .NET 11, переписал этот метод. Теперь он сразу возвращает результат по ModelMetadata.IsNullableValueType (это чинит ActionResult<int?>), а для ссылочных типов проходит по NullabilityInfo.GenericTypeArguments[0] сквозь Task<>/ValueTask<> (это чинит Task<Person?>). ActionResult<> он не разворачивает, поэтому для ссылочного типа NullabilityInfo останавливается на ActionResult<Person?>, который не null, и результат по-прежнему false. В описании PR об этом прямо сказано: обработка ссылочных типов “is still not handled very well in this PR”. Тег RC2 и main содержат тот же код, что и RC1, так что не ждите изменений до GA.

Исправление: трансформер операций, который разворачивает ActionResult

Встроенный конвейер передаёт в трансформер операции полный ApiDescription, и этого достаточно, чтобы выполнить проверку заново, но правильно. Этот трансформер обрабатывает два случая: допустимость null, которую можно вывести из возвращаемого типа, и явный атрибут [NullableResponse(statusCode)] для действий с IActionResult, где выводить нечего.

// .NET 10 and .NET 11 (Microsoft.AspNetCore.OpenApi 10.0.x / 11.0.0-rc.2)
using System.Reflection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class NullableResponseAttribute(int statusCode) : Attribute
{
    public int StatusCode { get; } = statusCode;
}

public sealed class NullableResponseTransformer : IOpenApiOperationTransformer
{
    public Task TransformAsync(
        OpenApiOperation operation,
        OpenApiOperationTransformerContext context,
        CancellationToken cancellationToken)
    {
        if (context.Description.ActionDescriptor is not ControllerActionDescriptor action
            || operation.Responses is null)
        {
            return Task.CompletedTask;
        }

        // 1. Explicit opt-in: [NullableResponse(200)]
        var statusCodes = action.MethodInfo
            .GetCustomAttributes<NullableResponseAttribute>()
            .Select(a => a.StatusCode)
            .ToHashSet();

        // 2. Inferred from the return type: T?, ActionResult<T?>, and their Task/ValueTask forms
        if (TryGetNullableResponseType(action.MethodInfo, out var valueType))
        {
            foreach (var responseType in context.Description.SupportedResponseTypes)
            {
                if (responseType.Type == valueType && responseType.StatusCode is >= 200 and < 300)
                {
                    statusCodes.Add(responseType.StatusCode);
                }
            }
        }

        foreach (var statusCode in statusCodes)
        {
            if (!operation.Responses.TryGetValue(statusCode.ToString(), out var response)
                || response.Content is null)
            {
                continue;
            }

            foreach (var mediaType in response.Content.Values)
            {
                // Microsoft.OpenApi 3.x (.NET 11) exposes IOpenApiMediaType with a read-only Schema.
                if (mediaType is OpenApiMediaType { Schema: { } schema } concrete && !IsNullWrapper(schema))
                {
                    concrete.Schema = new OpenApiSchema
                    {
                        OneOf = [new OpenApiSchema { Type = JsonSchemaType.Null }, schema]
                    };
                }
            }
        }

        return Task.CompletedTask;
    }

    private static bool TryGetNullableResponseType(MethodInfo method, out Type? valueType)
    {
        var type = method.ReturnType;
        var nullability = new NullabilityInfoContext().Create(method.ReturnParameter);

        if (type.IsGenericType
            && (type.GetGenericTypeDefinition() == typeof(Task<>)
                || type.GetGenericTypeDefinition() == typeof(ValueTask<>)))
        {
            type = type.GetGenericArguments()[0];
            nullability = nullability.GenericTypeArguments[0];
        }

        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ActionResult<>))
        {
            type = type.GetGenericArguments()[0];
            nullability = nullability.GenericTypeArguments[0];
        }

        valueType = type;
        return nullability.WriteState == NullabilityState.Nullable;
    }

    private static bool IsNullWrapper(IOpenApiSchema schema) =>
        schema.OneOf?.Any(s => s.Type == JsonSchemaType.Null) == true;
}

Зарегистрируйте его для каждого публикуемого документа:

// .NET 10 / .NET 11 RC2
builder.Services.AddControllers();
builder.Services.AddOpenApi(o => o.AddOperationTransformer<NullableResponseTransformer>());

А атрибут используйте там, где возвращаемый тип ничего не сообщает:

// .NET 10 / .NET 11 RC2
[HttpGet("attr")]
[ProducesResponseType<Person>(StatusCodes.Status200OK)]
[NullableResponse(StatusCodes.Status200OK)]
public IActionResult GetAttr() => Ok(null);

С зарегистрированным трансформером каждая строка таблицы выше дала обёртку oneOf на обеих версиях, включая Task<Person?> в .NET 10. ActionResult<Person>, не допускающий null, остался простым $ref.

Несколько проектных решений стоит пояснить:

Общую механику трансформеров (порядок, активацию через DI, объект контекста) смотрите в подробном разборе трансформеров.

Ловушка: null-результат контроллера это 204, а не 200

Вот что те же endpoints реально вернули по сети, одинаково в .NET 10 и .NET 11 RC2:

GET /people/actionresult   -> HTTP/1.1 204 No Content   (no body)
GET /people/plain          -> HTTP/1.1 204 No Content
GET /people/taskplain      -> HTTP/1.1 204 No Content
GET /minimal/maybe         -> HTTP/1.1 200 OK, application/json, body: null

MVC по умолчанию регистрирует HttpNoContentOutputFormatter, у которого свойство TreatNullValueAsNoContent по умолчанию равно true: любой объектный результат null превращается в 204 с пустым телом. В Minimal API такого форматтера нет, поэтому они действительно отправляют 200 с JSON-литералом null.

Это значит, что для контроллеров собственная обёртка oneOf фреймворка у голого Person? уже описывает ответ, которого сервер никогда не отправляет: клиент, сгенерированный из такой спецификации, ожидает 200 с null, а получает 204. Поэтому выберите один из двух честных контрактов.

Вариант A: оставить 204 и задокументировать его

Не трогайте MVC, оставьте схему 200 без null и объявите 204:

// .NET 10 / .NET 11 RC2: null result -> 204 No Content
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult<Person?>> Get(int id) => await _people.FindAsync(id);

В такой конфигурации обёртка для допустимости null на 200 не нужна, поэтому не регистрируйте трансформер, и помните, что у голого возвращаемого Person? обёртка от фреймворка всё равно появится. Большинство генераторов клиентов трактуют 204 без тела как “результата нет”, что обычно и нужно вызывающей стороне.

Вариант B: отправлять 200 с null и пометить 200 как допускающий null

Отключите преобразование в no-content, а затем пусть трансформер опишет результат:

// .NET 10 / .NET 11 RC2: null result -> 200 OK with body "null"
using Microsoft.AspNetCore.Mvc.Formatters;

builder.Services.AddControllers(o =>
{
    var noContent = o.OutputFormatters.OfType<HttpNoContentOutputFormatter>().Single();
    noContent.TreatNullValueAsNoContent = false;
});
builder.Services.AddOpenApi(o => o.AddOperationTransformer<NullableResponseTransformer>());

После этого изменения GET /people/actionresult вернул HTTP/1.1 200 OK, Content-Type: application/json; charset=utf-8, тело null, что в точности соответствует схеме oneOf. Действие void не изменилось (и до, и после оно возвращало 200 с пустым телом). На странице Microsoft Learn про форматирование показан другой способ, o.OutputFormatters.RemoveType<HttpNoContentOutputFormatter>(); я предпочитаю переключать свойство, потому что оно явно выражает намерение и не затрагивает остальные случаи форматтера.

Используйте вариант B, когда API потребляет что-то, уже ожидающее 200/null, или когда вы переносите endpoints между контроллерами и Minimal API и хотите, чтобы они вели себя одинаково. Во всех остальных случаях используйте вариант A: это поведение фреймворка по умолчанию, и трансформер не нужен.

Подводные камни и граничные случаи

Любой [ProducesResponseType] убирает выведенный 200. В первом тесте я поставил на действие ActionResult<Person?> только [ProducesResponseType(StatusCodes.Status404NotFound)], и ответ 200 полностью исчез из документа, так что трансформеру нечего было оборачивать. Как только у действия есть явные метаданные ответов, ApiExplorer перестаёт добавлять 200 по умолчанию для ActionResult<T>. Добавьте [ProducesResponseType(StatusCodes.Status200OK)] (тип не нужен, T подставится сам) рядом с кодами ошибок.

string? это не JSON. Действие с string?, возвращающее null, вернуло 200 с Content-Type: text/plain и пустым телом, когда TreatNullValueAsNoContent был выключен, потому что для строк побеждает StringOutputFormatter. Документ при этом по-прежнему объявляет application/json с допускающей null строкой. Если вы возвращаете строки из контроллеров, добавьте [Produces("application/json")] или возвращайте небольшой DTO.

Вывод OpenAPI 3.0 выглядит иначе, но означает то же самое. При o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 Microsoft.OpenApi сериализует ветку null как { "enum": [null], "nullable": true } внутри oneOf. Это корректно для 3.0, и генераторы это понимают. По умолчанию используется 3.1.1 в .NET 10 и 3.2.0 в .NET 11; если Swagger UI отказывается открывать документ 3.2, см. исправление Swagger UI для .NET 11.

Контекст допустимости null имеет значение. NullabilityInfoContext читает метаданные [Nullable] компилятора. В файле или проекте с отключёнными ссылочными типами, допускающими null, Person? вызывает предупреждение, а Person сообщается как Unknown, а не Nullable, поэтому ни фреймворк, ни трансформер ничего не помечают. Это правильный исход: код без аннотаций не даёт никаких обещаний в ту или иную сторону.

Типизированные результаты это отдельный путь кода. Minimal API и контроллеры, возвращающие Results<Ok<Person>, NotFound>, описывают ответы через IEndpointMetadataProvider, а не через возвращаемый тип метода. Я проверил и Minimal API, возвращающий Results<Ok<Person?>, NotFound>, и действие контроллера, возвращающее Ok<Person?>: в .NET 10 и .NET 11 RC2 схема 200 была простой ссылкой на Person. Приведённый выше трансформер тоже ничего не выводит из Ok<T>, поэтому ставьте [NullableResponse(200)] на такие действия контроллеров (для Minimal API используйте AddOperationTransformer с проверкой по маршруту, поскольку там нет ControllerActionDescriptor). Если вы выбираете между формами, типизированные результаты, IResult и IActionResult объясняют, что каждая из них сообщает ApiExplorer.

Не “чините” это в трансформере схемы. Соблазн сделать Person допускающим null глобально, чтобы каждый $ref принимал null, понятен. Но вы теряете различие между endpoints, а в OpenAPI 3.1+ обёртка oneOf, которую фреймворк добавляет в других местах, получает две ветви, обе из которых соответствуют null, и oneOf не проходит валидацию (он требует ровно одного совпадения). В PR #67661 именно эта причина названа для того, чтобы внутренняя схема оставалась без null.

Читайте дальше

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад