ASP.NET Core×Swagger:dynamic返却でスキーマが表示されない問題の原因と解決策(Swashbuckle/NSwag完全ガイド)

ASP.NET Core の Web API を Swagger(Swashbuckle/NSwag)で公開したとき、コントローラの戻り値を dynamic にすると UI のレスポンス・スキーマが空になる――この現象は「仕様」を正しく理解すれば避けられます。本記事では原因から実務的な回避策、サンプル実装、運用のコツまでをまとめて解説します。

目次

問題の背景と現象

まず、なぜ dynamic を返すと Swagger UI にスキーマが表示されないのかを整理します。

  • Swagger(OpenAPI)に必要なのはコンパイル時の型情報… スキーマは「プロパティ名・型・必須かどうか」を静的に表現します。
  • dynamic は実行時バインディング… C# のコンパイラは型情報を保持しないため、リフレクションだけではメンバー構造を確定できません。
  • Swashbuckle/NSwag は型メタデータからスキーマを生成… 返却型が dynamic(または object)だと、プロパティを列挙できず「不明」=空欄に見える状態になります。

結果として、Swagger UI では 200 応答の「Schema」が表示されず、クライアント生成や検証に役立つ情報が失われます。API 消費者の体験を損ねるだけでなく、仕様変更の検出も困難になります。

最短の答え:DTO + ActionResult<T>(推奨)

結論から言えば「返却用の DTO を定義して ActionResult<T> を返す」が最も簡潔かつ堅牢です。Swagger は静的型 T から完全なスキーマを起こせるため、プロパティ定義・必須項目・列挙値・フォーマット(email、date-time 等)を正確に UI に反映できます。さらに OpenAPI からのクライアント自動生成(NSwagStudio / OpenAPI Generator など)と組み合わせると、型安全な SDK を配布できます。

コントローラ(属性ルーティング)での実装例

public record MyResponseDto(string Name, int Age);

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(MyResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult Get(int id)
{
// 実アプリでは DB 等から取得
if (id == 404) return NotFound();

```
    var dto = new MyResponseDto("Alice", 25);
    return Ok(dto);
}
```

} 

Minimal APIs での実装例(.NET 7+)

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();

app.MapGet("/api/users/{id:int}",
Results, NotFound> (int id) =>
{
if (id == 404) return TypedResults.NotFound();
return TypedResults.Ok(new MyResponseDto("Alice", 25));
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

app.Run();

public record MyResponseDto(string Name, int Age); 

Minimal APIs の Results<...> を使うと、成功/失敗の複数ステータスを静的に表明できます。Swagger はそれぞれの status code に対してスキーマと説明を表示します。

他の現実解:妥協の度合いと用途で選ぶ

返却形が実行時まで確定できないケースでは、次のような代替も現実的です。空欄を避け、利用者に最低限のヒントを提供できます。

解決策内容メリットデメリット
① DTO + ActionResult<T>(推奨)返却用クラス (DTO) を定義し、ActionResult<MyDto> を返す。例:
[ProducesResponseType(typeof(MyDto), 200)]
Swagger が完全な型情報を取得し、詳細なスキーマを自動生成できるDTO を都度作成する手間
② JsonObject / JObject を返す例:return Ok(JsonNode.Parse("{"foo":"bar"}"));UI では「汎用 object」と表示され、空欄にならないプロパティ構造は具体的に示されない
③ oneOf/anyOf で複数 DTO を列挙可能性のある返却形を列挙して公開するバリエーションを正規にドキュメント化宣言が煩雑/運用コスト増
④ Example でサンプル JSON を提示スキーマではなく例だけ示す(Example Filter など)DTO が作れない場合でも UI でイメージ共有スキーマ検証はできない
⑤ object を返す[ProducesResponseType(typeof(object), 200)]最低限「object」は表示情報量はほぼ無い

② JsonObject / JObject を返して「空欄」を回避

dynamic をそのまま返すのではなく、System.Text.Json.Nodes.JsonObject(または Newtonsoft.Json.Linq.JObject)に包むと、Swagger は「object(任意のプロパティ)」として扱ってくれます。完全なスキーマではないものの、UI が空白になるよりは遥かに親切です。

// System.Text.Json 版
using System.Text.Json;
using System.Text.Json.Nodes;

[HttpGet("raw")]
public IActionResult GetRaw()
{
var json = new JsonObject
{
["foo"] = "bar",
["count"] = 3
};
return Ok(json); // Swagger 上は type: object と表示
} 

Newtonsoft.Json を使う場合は AddControllers().AddNewtonsoftJson() を有効化し、JObject を返します。

③ 返却形が複数あるなら oneOf/anyOf

「成功時に UserDto か AdminDto のどちらか」など、複数形があり得る場合は oneOf を使います。Swashbuckle では素の属性だけで 200 応答の oneOf を作るのが難しいため、OperationFilter でスキーマを書き換えるのが実務的です。

public class OneOfResponseOperationFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        if (!operation.Responses.TryGetValue("200", out var resp)) return;
        if (!resp.Content.TryGetValue("application/json", out var media)) return;

```
    var s1 = context.SchemaGenerator.GenerateSchema(typeof(UserDto), context.SchemaRepository);
    var s2 = context.SchemaGenerator.GenerateSchema(typeof(AdminDto), context.SchemaRepository);

    media.Schema.OneOf = new List&lt;OpenApiSchema&gt; { s1, s2 };
    media.Schema.Nullable = false;
}
```

} 

Startup/Program 側で services.AddSwaggerGen(c => c.OperationFilter<OneOfResponseOperationFilter>()); を登録してください。NSwag でも同様に Operation Processor を用意できます。

④ Example(サンプル JSON)を提示して合意を形成

仕様が固まりきっていないプロトタイピング期は、例 を見せて関係者の合意形成を進めるのが効果的です。Swashbuckle の Examples 機能(Example Filter)や OperationFilter でレスポンス例を設定すれば、スキーマ空欄の心理的障壁を下げられます。

// Swashbuckle.AspNetCore.Filters を利用する例
public class MyResponseExample : IExamplesProvider<MyResponseDto>
{
    public MyResponseDto GetExamples() => new("Alice", 25);
}

// Startup
services.AddSwaggerGen(c => c.ExampleFilters());
services.AddSwaggerExamplesFromAssemblyOf(); 

スキーマ検証はできませんが、UI 上でデータ形状の「当たり」を共有できます。

⑤ 最低限の逃げ道:object を返す

object 型は OpenAPI 上で「任意のオブジェクト」を意味します。情報量は少ないものの、dynamic よりは「何かしらの JSON オブジェクト」が返ることを表明できます。

[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
public IActionResult GetLoose() =&gt; Ok(new { message = "anything" });

dynamic をどうしても使いたい時のテクニック

要件上どうしても dynamic(または object)にせざるを得ない場合、Swagger 上の見え方を改善するテクニックを紹介します。

SchemaFilter で「自由形式オブジェクト」を明示

ISchemaFilter を使い、object/JsonNode/JsonElement に遭遇したら additionalProperties を許可するスキーマへ置き換えます。これにより UI は「任意のキーを持つオブジェクト」と認識します。

public class DynamicSchemaFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        var t = context.Type;
        if (t == typeof(object) || t == typeof(System.Text.Json.Nodes.JsonNode)
            || t == typeof(System.Text.Json.JsonElement))
        {
            schema.Type = "object";
            schema.AdditionalPropertiesAllowed = true;
            schema.AdditionalProperties = new OpenApiSchema(); // any
            schema.Properties?.Clear();
        }
    }
}

// Startup
services.AddSwaggerGen(c => c.SchemaFilter()); 

Envelope パターンで外形を固定する

中身は自由でも、外側のフォーマット(メタ情報)は固定すると、ドキュメント品質が大幅に上がります。

public record EnvelopeDto(string Kind, JsonObject Data, DateTimeOffset Timestamp);

[HttpGet("search")]
[ProducesResponseType(typeof(EnvelopeDto), StatusCodes.Status200OK)]
public ActionResult Search(string q)
{
var payload = new JsonObject { ["q"] = q, ["hits"] = 123 };
return Ok(new EnvelopeDto("searchResult", payload, DateTimeOffset.UtcNow));
} 

この形なら Kind や Timestamp は強く型付けでき、Data は自由形式として公開できます。

NSwag を使う場合のポイント

  • アノテーション… [SwaggerResponse(200, typeof(MyDto), Description = "OK")] で戻り値を明示できます。
  • ポリモーフィズム… NJsonSchema の discriminator(継承階層)を使って oneOf を表現できます。基底 DTO を定義しサブタイプに派生させると、UI は型のバリエーションを把握します。
  • 例の提示… Operation Processor で OpenApiExample を付与可能です。

よくある落とし穴と対策

  • JSON シリアライザの不一致… プロジェクトが System.Text.Json なのに JObject を返すと期待通りに動かない場合があります。Newtonsoft を使うなら AddNewtonsoftJson() を有効に。
  • ProducesResponseType の未設定… ActionResult を返しているのに属性で型を指定していないと、Swagger からは判別しにくいです。成功・失敗ともに明示しましょう。
  • 配列・辞書の型引数未指定… IEnumerable や IDictionary を返す場合は、要素型・値型を必ず具体化(IEnumerable<ItemDto> / Dictionary<string, string>)。
  • Minimal APIs のメタデータ不足… ラムダの戻り値が曖昧だと推論できません。Results<...> や .Produces<T>() を付けてください。
  • バージョン管理なし… 返却形の変更を伴う場合は API Versioning を導入して互換性を担保します。

実装テンプレート集(コピペ可)

1) 標準的な DTO 返却(Controller)

public record ErrorDto(string Code, string Message);

[HttpGet("{id:int}")]
[ProducesResponseType(typeof(MyResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorDto), StatusCodes.Status404NotFound)]
public ActionResult GetById(int id)
{
var entity = Find(id);
if (entity is null)
return NotFound(new ErrorDto("NotFound", $"User {id} was not found"));

```
return Ok(new MyResponseDto(entity.Name, entity.Age));
```

} 

2) oneOf(ユーザー種別で返却形が変わる)

public record UserBase(string Id, string Kind);
public record MemberUser(string Id, string Kind, DateTime JoinedAt) : UserBase(Id, Kind);
public record AdminUser(string Id, string Kind, string Role)        : UserBase(Id, Kind);

// OperationFilter で 200 応答の oneOf として MemberUser / AdminUser を設定 

3) Example のみ提示(サンプル JSON)

public class SearchExample : IExamplesProvider&lt;JsonObject&gt;
{
    public JsonObject GetExamples() =&gt; new()
    {
        ["query"] = "alice",
        ["total"] = 2,
        ["items"] = new JsonArray("A", "B")
    };
}

4) どうしても柔軟にしたい時の辞書返却

[ProducesResponseType(typeof(Dictionary&lt;string, object&gt;), StatusCodes.Status200OK)]
public ActionResult&lt;Dictionary&lt;string, object&gt;&gt; GetMap()
{
    return Ok(new Dictionary&lt;string, object&gt;
    {
        ["foo"] = "bar",
        ["answer"] = 42
    });
}

検証の手順(手元で再現)

  1. 新規の ASP.NET Core Web API を作成し、AddEndpointsApiExplorer() と AddSwaggerGen() を有効化。
  2. dynamic を返すエンドポイントと、DTO を返すエンドポイントを一つずつ用意。
  3. Swagger UI を開き、両者の「Schema」欄の違いを比較。
  4. SchemaFilter/OperationFilter を順に導入し、UI の変化を確認。
  5. OpenAPI JSON(/swagger/v1/swagger.json)の schemas に生成された型が登録されているかを確認。

設計判断の指針(チェックリスト)

  • 返却形は実行前に決められるか? → はい:DTO 化する(推奨)。
  • 返却形の候補が有限か? → はい:oneOf で列挙する。
  • 形は流動的だが「外形」は固定できるか? → はい:Envelope(metadata + data)パターン。
  • 形も外形も流動的か? → はい:JsonObject 返却 + Example 提示 + SchemaFilter。

パフォーマンス・保守性への影響

  • DTO は最適化しやすい… JsonSerializerContext(Source Generator)を併用するとシリアライズの割当が減ります。
  • 自由形式はテストが増える… スキーマ検証が効かないため、E2E テストや契約テストで補完しましょう。
  • 自動生成クライアントの恩恵… DTO 化すれば型安全な SDK を配布でき、呼び出し側の Null 安全・変更検知が効きます。

運用のベストプラクティス

  • 共通エラー DTO… ProblemDetails または独自 ErrorDto を全 API で統一し、[ProducesResponseType(typeof(ProblemDetails), 400)] のように明記。
  • ステータス毎にドキュメント化… 200/400/404/409/500 など、実際に返し得るものは漏れなく列挙。
  • バージョニング… 返却形の破壊的変更は新しい API バージョンで。
  • Example の鮮度… 仕様変更時は Example も更新。UI の印象は重要です。

トラブルシューティング早見表

症状原因の目安対処
Schema が空欄返却型が dynamic/objectDTO 化 or JsonObject 返却 or SchemaFilter
200 に複数形がある仕様が分岐oneOf を OperationFilter で設定
Minimal API でスキーマ推論されない戻り値が曖昧Results<...> と .Produces<T>() を追加
Newtonsoft と混在Serializer 設定不一致AddNewtonsoftJson() か System.Text.Json に統一

まとめ

Swagger に戻り値を「きちんと」示したいなら、DTO + ActionResult<T> が最も確実で、ドキュメント品質・自動生成・保守性のすべてでメリットが大きい選択です。要件上 dynamic/object を避けられない場面でも、JsonObject 返却・oneOf の明示・Example の提示・SchemaFilter の導入といった工夫で、Swagger UI の空白をなくし、API 利用者の理解コストを最小化できます。設計初期から「コンパイル時に説明できる型」を意識しておくことが、開発速度と品質の両立に直結します。


付録:完全サンプル(Swashbuckle 構成)

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
    .AddJsonOptions(o => o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo API", Version = "v1" });
    c.SchemaFilter<DynamicSchemaFilter>();      // 任意
    c.OperationFilter<OneOfResponseOperationFilter>(); // 任意
    // c.ExampleFilters(); // Swashbuckle.AspNetCore.Filters を使う場合
});

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();

app.MapControllers();
app.Run();

// --- DTOs ---
public record MyResponseDto(string Name, int Age);
public record ErrorDto(string Code, string Message);

// --- Filters ---
public class DynamicSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var t = context.Type;
if (t == typeof(object) || t == typeof(System.Text.Json.Nodes.JsonNode)
|| t == typeof(System.Text.Json.JsonElement))
{
schema.Type = "object";
schema.AdditionalPropertiesAllowed = true;
schema.AdditionalProperties = new OpenApiSchema();
schema.Properties?.Clear();
}
}
}

public class OneOfResponseOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (!operation.Responses.TryGetValue("200", out var resp)) return;
if (!resp.Content.TryGetValue("application/json", out var media)) return;

```
    // 例として MyResponseDto と ErrorDto の二択を oneOf にする
    var s1 = context.SchemaGenerator.GenerateSchema(typeof(MyResponseDto), context.SchemaRepository);
    var s2 = context.SchemaGenerator.GenerateSchema(typeof(ErrorDto), context.SchemaRepository);
    media.Schema.OneOf = new List&lt;OpenApiSchema&gt; { s1, s2 };
}
```

} 

補足メモ

  • record 型で DTO を簡潔に… C# 9+ の record はイミュータブル DTO に最適。init アクセサで柔軟性も確保。
  • 共通エラーの統一… ProblemDetails を使えば RFC7807 形式で機械可読なエラーを整備できます。
  • バリデーション属性… [Required] や [Range] を DTO に付けると Swagger スキーマにも反映され、UI の信頼性が上がります。
  • クライアント生成との相性… DTO 定義があるほど生成クライアントの型安全性・保守性は高く、フロント側の開発効率も向上します。

結論:API 利用者へレスポンス構造を正確に伝えたいなら DTO + ActionResult<T> が最も確実。
dynamic/object で妥協する場合でも、JsonObject やサンプル JSON、oneOf、SchemaFilter などで「空欄」を残さない工夫を行い、仕様の意思疎通と将来の保守性を担保しましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次