mcpField
D
dotnet-legacy-api-controllers

dotnet-legacy-api-controllers

github.com/ronnythedev開発7760 回インストール15 週間前に更新
リポジトリ

dotnet-legacy-api-controllers について

.NET の RESTful API コントローラーを生成するスキルです。CRUD や複雑な操作のテンプレートに、MediatR の ISender、API バージョニング、認可、DTO、HTTP レスポンス規約が含まれます。

主な機能

  1. 01CRUD コントローラーテンプレート
  2. 02MediatR ISender 連携
  3. 03URL セグメント方式の API バージョニング
  4. 04ロール・権限ベースの認可
  5. 05リクエスト・レスポンス DTO
  6. 06HTTP ステータスコード規約

活用シーン

  1. 01バージョン付き REST エンドポイントの作成
  2. 02コントローラーとコマンド・クエリの接続
  3. 03API 操作への認可設定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-legacy-api-controllers

インストール後、Claude Code で/dotnet-legacy-api-controllersを入力して読み込まれたか確認します。

詳細

API コントローラー生成ツール

概要

このスキルでは、ベストプラクティスに従って RESTful API コントローラーを生成します。

  • MediatR との統合 - ISender を介してコマンド/クエリを送信
  • API バージョニング - URL セグメントによるバージョニング
  • 認可 - ロールおよび権限ベース
  • 一貫したレスポンス - 適切な HTTP ステータスコード
  • リクエスト/レスポンス DTO - ドメインとは分離

クイックリファレンス

HTTP メソッドアクション戻り値
GET /{id}ID で取得200 OK / 404 Not Found
GET /すべて取得/一覧表示200 OK
POST /作成201 Created / 400 Bad Request
PUT /{id}全体更新200 OK / 404 Not Found
PATCH /{id}部分更新200 OK / 404 Not Found
DELETE /{id}削除204 No Content / 404 Not Found

コントローラーの構造

/API/Controllers/
├── {Feature}/
│   ├── {Entity}Controller.cs
│   ├── Request{Action}{Entity}.cs
│   └── ...
└── ...

テンプレート:完全な CRUD コントローラー

// src/{name}.api/Controllers/{Feature}/{Entity}Controller.cs
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using {name}.application.{feature}.Create{Entity};
using {name}.application.{feature}.Delete{Entity};
using {name}.application.{feature}.Get{Entity}ById;
using {name}.application.{feature}.Get{Entities};
using {name}.application.{feature}.Update{Entity};
using {name}.infrastructure.authorization;

namespace {name}.api.Controllers.{Feature};

[Authorize]
[ApiController]
[ApiVersion(ApiVersions.V1)]
[Route("api/v{version:apiVersion}/{entities}")]
public class {Entity}Controller : ControllerBase
{
    private readonly ISender _sender;

    public {Entity}Controller(ISender sender)
    {
        _sender = sender;
    }

    // ═══════════════════════════════════════════════════════════════
    // GET: api/v1/{entities}/{id}
    // ═══════════════════════════════════════════════════════════════
    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof({Entity}Response), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var query = new Get{Entity}ByIdQuery(id);

        var result = await _sender.Send(query, cancellationToken);

        if (result.IsFailure)
        {
            return NotFound(result.Error);
        }

        return Ok(result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // GET: api/v1/{entities}
    // ═══════════════════════════════════════════════════════════════
    [HttpGet]
    [ProducesResponseType(typeof(IReadOnlyList<{Entity}ListResponse>), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
    {
        var query = new GetAll{Entities}Query();

        var result = await _sender.Send(query, cancellationToken);

        return Ok(result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // GET: api/v1/{entities}/organization/{organizationId}
    // ═══════════════════════════════════════════════════════════════
    [HttpGet("organization/{organizationId:guid}")]
    [HasPermission(Permissions.{Entities}Read)]
    [ProducesResponseType(typeof(IReadOnlyList<{Entity}Response>), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetByOrganizationId(
        Guid organizationId,
        CancellationToken cancellationToken)
    {
        var query = new Get{Entities}ByOrganizationIdQuery(organizationId);

        var result = await _sender.Send(query, cancellationToken);

        return Ok(result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // POST: api/v1/{entities}
    // ═══════════════════════════════════════════════════════════════
    [HttpPost]
    [HasPermission(Permissions.{Entities}Write)]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] RequestCreate{Entity} request,
        CancellationToken cancellationToken)
    {
        var command = new Create{Entity}Command(
            request.Name,
            request.Description,
            request.OrganizationId);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return BadRequest(result.Error);
        }

        return CreatedAtAction(
            nameof(GetById),
            new { id = result.Value },
            result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // PUT: api/v1/{entities}/{id}
    // ═══════════════════════════════════════════════════════════════
    [HttpPut("{id:guid}")]
    [HasPermission(Permissions.{Entities}Write)]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Update(
        Guid id,
        [FromBody] RequestUpdate{Entity} request,
        CancellationToken cancellationToken)
    {
        var command = new Update{Entity}Command(
            id,
            request.Name,
            request.Description);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return result.Error.Code.Contains("NotFound")
                ? NotFound(result.Error)
                : BadRequest(result.Error);
        }

        return Ok();
    }

    // ═══════════════════════════════════════════════════════════════
    // PATCH: api/v1/{entities}/{id}
    // ═══════════════════════════════════════════════════════════════
    [HttpPatch("{id:guid}")]
    [HasPermission(Permissions.{Entities}Write)]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> PartialUpdate(
        Guid id,
        [FromBody] RequestPatch{Entity} request,
        CancellationToken cancellationToken)
    {
        var command = new Patch{Entity}Command(id, request);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return result.Error.Code.Contains("NotFound")
                ? NotFound(result.Error)
                : BadRequest(result.Error);
        }

        return Ok();
    }

    // ═══════════════════════════════════════════════════════════════
    // DELETE: api/v1/{entities}/{id}
    // ═══════════════════════════════════════════════════════════════
    [HttpDelete("{id:guid}")]
    [HasPermission(Permissions.{Entities}Write)]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Delete(
        Guid id,
        CancellationToken cancellationToken)
    {
        var command = new Delete{Entity}Command(id);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return result.Error.Code.Contains("NotFound")
                ? NotFound(result.Error)
                : BadRequest(result.Error);
        }

        return NoContent();
    }
}

テンプレート:リクエスト DTO

// src/{name}.api/Controllers/{Feature}/RequestCreate{Entity}.cs
namespace {name}.api.Controllers.{Feature};

public sealed class RequestCreate{Entity}
{
    public required string Name { get; init; }
    public string? Description { get; init; }
    public Guid OrganizationId { get; init; }
}

// src/{name}.api/Controllers/{Feature}/RequestUpdate{Entity}.cs
public sealed class RequestUpdate{Entity}
{
    public required string Name { get; init; }
    public string? Description { get; init; }
}

// src/{name}.api/Controllers/{Feature}/RequestPatch{Entity}.cs
public sealed class RequestPatch{Entity}
{
    public string? Name { get; init; }
    public string? Description { get; init; }
    public bool? IsActive { get; init; }
}

テンプレート:複雑な操作を行うコントローラー

// src/{name}.api/Controllers/{Feature}/{Entity}Controller.cs
[Authorize]
[ApiController]
[ApiVersion(ApiVersions.V1)]
[Route("api/v{version:apiVersion}/{entities}")]
public class {Entity}Controller : ControllerBase
{
    private readonly ISender _sender;
    private readonly IConfiguration _configuration;

    public {Entity}Controller(ISender sender, IConfiguration configuration)
    {
        _sender = sender;
        _configuration = configuration;
    }

    // ═══════════════════════════════════════════════════════════════
    // POST: api/v1/{entities}/batch
    // ═══════════════════════════════════════════════════════════════
    [HttpPost("batch")]
    [HasPermission(Permissions.{Entities}Write)]
    public async Task<IActionResult> CreateBatch(
        [FromBody] RequestCreateBatch{Entity} request,
        CancellationToken cancellationToken)
    {
        var command = new CreateBatch{Entity}Command(request);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return BadRequest(result.Error);
        }

        return Ok(result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // POST: api/v1/{entities}/{id}/activate
    // ═══════════════════════════════════════════════════════════════
    [HttpPost("{id:guid}/activate")]
    [HasPermission(Permissions.{Entities}Write)]
    public async Task<IActionResult> Activate(
        Guid id,
        CancellationToken cancellationToken)
    {
        var command = new Activate{Entity}Command(id);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return BadRequest(result.Error);
        }

        return Ok();
    }

    // ═══════════════════════════════════════════════════════════════
    // POST: api/v1/{entities}/{id}/deactivate
    // ═══════════════════════════════════════════════════════════════
    [HttpPost("{id:guid}/deactivate")]
    [HasPermission(Permissions.{Entities}Write)]
    public async Task<IActionResult> Deactivate(
        Guid id,
        CancellationToken cancellationToken)
    {
        var command = new Deactivate{Entity}Command(id);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return BadRequest(result.Error);
        }

        return Ok();
    }

    // ═══════════════════════════════════════════════════════════════
    // GET: api/v1/{entities}/search
    // ═══════════════════════════════════════════════════════════════
    [HttpGet("search")]
    public async Task<IActionResult> Search(
        [FromQuery] string? term,
        [FromQuery] int pageNumber = 1,
        [FromQuery] int pageSize = 10,
        CancellationToken cancellationToken = default)
    {
        var query = new Search{Entities}Query(term, pageNumber, pageSize);

        var result = await _sender.Send(query, cancellationToken);

        return Ok(result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // POST: api/v1/{entities}/{parentId}/children
    // ═══════════════════════════════════════════════════════════════
    [HttpPost("{parentId:guid}/children")]
    [HasPermission(Permissions.{Entities}Write)]
    public async Task<IActionResult> AddChild(
        Guid parentId,
        [FromBody] RequestAddChild request,
        CancellationToken cancellationToken)
    {
        var command = new AddChildCommand(parentId, request.Name, request.SortOrder);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return BadRequest(result.Error);
        }

        return Created($"api/v1/{entities}/{parentId}/children/{result.Value}", result.Value);
    }

    // ═══════════════════════════════════════════════════════════════
    // DELETE: api/v1/{entities}/{parentId}/children/{childId}
    // ═══════════════════════════════════════════════════════════════
    [HttpDelete("{parentId:guid}/children/{childId:guid}")]
    [HasPermission(Permissions.{Entities}Write)]
    public async Task<IActionResult> RemoveChild(
        Guid parentId,
        Guid childId,
        CancellationToken cancellationToken)
    {
        var command = new RemoveChildCommand(parentId, childId);

        var result = await _sender.Send(command, cancellationToken);

        if (result.IsFailure)
        {
            return BadRequest(result.Error);
        }

        return NoContent();
    }
}

テンプレート:ロールベースの認可を行うコントローラー

// src/{name}.api/Controllers/{Feature}/{Entity}Controller.cs
[Authorize]
[ApiController]
[ApiVersion(ApiVersions.V1)]
[Route("api/v{version:apiVersion}/{entities}")]
public class {Entity}Controller : ControllerBase
{
    private readonly ISender _sender;

    public {Entity}Controller(ISender sender)
    {
        _sender = sender;
    }

    // Public endpoint (no specific role required, just authenticated)
    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        // ...
    }

    // Multiple roles allowed
    [HttpPost]
    [Authorize(Roles = Roles.SuperAdmin + "," + Roles.Manager)]
    public async Task<IActionResult> Create(
        [FromBody] RequestCreate{Entity} request,
        CancellationToken ct)
    {
        // ...
    }

    // Only super admin
    [HttpDelete("{id:guid}")]
    [Authorize(Roles = Roles.SuperAdmin)]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        // ...
    }

    // Permission-based (custom attribute)
    [HttpPut("{id:guid}")]
    [HasPermission(Permissions.{Entities}Write)]
    public async Task<IActionResult> Update(
        Guid id,
        [FromBody] RequestUpdate{Entity} request,
        CancellationToken ct)
    {
        // ...
    }

    // Anonymous endpoint
    [HttpGet("public")]
    [AllowAnonymous]
    public async Task<IActionResult> GetPublicData(CancellationToken ct)
    {
        // ...
    }
}

API バージョニングの設定

// src/{name}.api/ApiVersions.cs
namespace {name}.api;

public static class ApiVersions
{
    public const string V1 = "1.0";
    public const string V2 = "2.0";
}

// src/{name}.infrastructure/DependencyInjection.cs
private static void AddApiVersioning(IServiceCollection services)
{
    services.AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1);
        options.ReportApiVersions = true;
        options.ApiVersionReader = new UrlSegmentApiVersionReader();
        options.AssumeDefaultVersionWhenUnspecified = true;
    })
    .AddMvc();
}

権限ベースの認可

// src/{name}.infrastructure/Authorization/Permissions.cs
namespace {name}.infrastructure.authorization;

public static class Permissions
{
    // Organizations
    public const string OrganizationsRead = "organizations:read";
    public const string OrganizationsWrite = "organizations:write";

    // Users
    public const string UsersRead = "users:read";
    public const string UsersWrite = "users:write";

    // {Entities}
    public const string {Entities}Read = "{entities}:read";
    public const string {Entities}Write = "{entities}:write";
}

// src/{name}.infrastructure/Authorization/Roles.cs
namespace {name}.infrastructure.authorization;

public static class Roles
{
    public const string SuperAdmin = "SuperAdmin";
    public const string Admin = "Admin";
    public const string Manager = "Manager";
    public const string Consultant = "Consultant";
    public const string Associate = "Associate";
}

// src/{name}.infrastructure/Authorization/HasPermissionAttribute.cs
using Microsoft.AspNetCore.Authorization;

namespace {name}.infrastructure.authorization;

public sealed class HasPermissionAttribute : AuthorizeAttribute
{
    public HasPermissionAttribute(string permission) : base(permission)
    {
    }
}

グローバルエラー処理

// src/{name}.api/Middleware/ExceptionHandlingMiddleware.cs
using {name}.application.exceptions;
using Microsoft.AspNetCore.Mvc;

namespace {name}.api.Middleware;

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception exception)
        {
            _logger.LogError(exception, "Exception occurred: {Message}", exception.Message);

            var problemDetails = CreateProblemDetails(exception);

            context.Response.StatusCode = problemDetails.Status ?? 500;
            await context.Response.WriteAsJsonAsync(problemDetails);
        }
    }

    private static ProblemDetails CreateProblemDetails(Exception exception)
    {
        return exception switch
        {
            ValidationException validationException => new ProblemDetails
            {
                Status = StatusCodes.Status400BadRequest,
                Title = "Validation Error",
                Detail = "One or more validation errors occurred.",
                Extensions = { ["errors"] = validationException.Errors }
            },
            ConcurrencyException => new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = "Concurrency Error",
                Detail = "The record was modified by another user."
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = "Server Error",
                Detail = "An unexpected error occurred."
            }
        };
    }
}

// Extension method
public static class ExceptionHandlingMiddlewareExtensions
{
    public static IApplicationBuilder UseCustomExceptionHandler(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<ExceptionHandlingMiddleware>();
    }
}

REST の規約

操作HTTP メソッドURL成功コード失敗コード
1 件取得GET/{entities}/{id}200404
すべて取得GET/{entities}200-
フィルター付きで取得GET/{entities}?filter=x200-
子要素を取得GET/{entities}/{id}/children200404
作成POST/{entities}201400
全体更新PUT/{entities}/{id}200400, 404
部分更新PATCH/{entities}/{id}200400, 404
削除DELETE/{entities}/{id}204400, 404
アクションPOST/{entities}/{id}/action200400, 404

重要なルール

  1. IMediator ではなく ISender を注入する - 送信のみを行い、発行はしません
  2. CancellationToken を使用する - すべての非同期操作に渡します
  3. 適切なステータスコードを返す - 作成には 201、削除には 204 を使用します
  4. POST には CreatedAtAction を使用する - Location ヘッダーを返します
  5. DTO は API 層に配置する - アプリケーション層の DTO を直接公開しません
  6. ルート制約を使用する - 型安全性のために {id:guid} を使用します
  7. デフォルトで認可を有効にする - コントローラーに [Authorize] を付けます
  8. API をバージョニングする - 複数のバージョンをサポートします
  9. ProducesResponseType を使用する - 想定されるレスポンスを文書化します
  10. 例外をキャッチしない - ミドルウェアに処理を任せます

避けるべきアンチパターン

// ❌ WRONG: Business logic in controller
[HttpPost]
public async Task<IActionResult> Create([FromBody] Request request)
{
    if (await _repository.ExistsAsync(request.Name))
        return BadRequest("Already exists");  // Logic belongs in handler!
    
    var entity = new Entity { Name = request.Name };
    _repository.Add(entity);
    await _unitOfWork.SaveChangesAsync();
    return Ok(entity.Id);
}

// ✅ CORRECT: Controller only orchestrates
[HttpPost]
public async Task<IActionResult> Create([FromBody] Request request, CancellationToken ct)
{
    var command = new CreateCommand(request.Name);
    var result = await _sender.Send(command, ct);
    
    return result.IsFailure 
        ? BadRequest(result.Error) 
        : CreatedAtAction(nameof(GetById), new { id = result.Value }, result.Value);
}

// ❌ WRONG: Returning domain entities
[HttpGet("{id}")]
public async Task<User> GetById(Guid id)  // Exposes domain!

// ✅ CORRECT: Return DTOs
[HttpGet("{id}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
    var result = await _sender.Send(new GetQuery(id), ct);
    return result.IsFailure ? NotFound(result.Error) : Ok(result.Value);
}

// ❌ WRONG: Catching and wrapping exceptions
try { ... }
catch (Exception ex)
{
    return StatusCode(500, ex.Message);
}

// ✅ CORRECT: Let middleware handle exceptions
// No try-catch, middleware handles it globally

関連スキル

  • dotnet-cqrs-command-generator - コントローラー用のコマンドを生成
  • dotnet-cqrs-query-generator - コントローラー用のクエリを生成
  • dotnet-clean-architecture - プロジェクト全体の構造
  • dotnet-result-pattern - コマンド/クエリの結果を処理

コメント