OpenAPI 已成 Web API 的業界標準,背後有強大的生態體系,豐富的文件/程式碼產生器以測試工具,這些好處過去我已有所體會。(參考:再探 WebAPI 客戶端自動產生器 - AutoRest、NSwag 與 .NET 3.5 支援問題) 而隨著我的專案大多改用 ASP.NET Core Minimal API 開發,這篇就來看看如何讓 Minimal API 也支援 OpenAPI 規格。

MS Learn 有篇官方教學是最權威的指南,這裡簡單整理重點。

程式庫方面,.NET 7 起有官方提供的 Microsoft.AspNetCore.OpenApi,另外一般會搭配 Swashbuckle.AspNetCore(6.4+) 提供 API 文件產生、API 檢視及測試網頁功能。

參考文件,我用 Minimal API 實作一個支援 OpenAI 規格及 Swagger 介面的 Web API。練習自訂 Swagger 文件標題及說明、用 ExcludeFromDescription() 排除非 Web API 方法、WithName()/OperationId 指定作業識別碼、WitTags() 分群、定義參數說明、Produces() 指定回應型別、檢核錯誤回傳 HTTP 400 及錯誤訊息物件... 等技巧。

using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt => {
    opt.SwaggerDoc("v1", new OpenApiInfo { 
        Title = "Minimal API Demo", 
        Description = "Minimal API OpenAPI 整合範例"
    });
});
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger(); // 開發環境下使用 Swagger
    app.UseSwaggerUI(); // 啟用 Swagger 網頁介面
}

app.UseFileServer(); // 啟用 wwwroot 靜態檔案伺服器

app.MapGet("/", () => "Hello World!")
    .ExcludeFromDescription(); // 從 OpenAPI 文件排除

app.MapGet("/guid", () => {
    return Guid.NewGuid();
}).WithName("NewGuid").WithOpenApi()
// 指定作業識別碼為 NewGuid,並用預設值產生 OpenAPI 文件
.WithTags("生成函數");

app.MapGet("/randoms", (int count, int range = int.MaxValue) => {
    var random = new Random();
    var randoms = Enumerable.Range(0, count).Select(_ => random.Next(range));
    return randoms;
}).WithOpenApi(op => {
    op.OperationId = "GetRandoms"; // 另一種指定作業識別碼的方式
    op.Summary = "產生隨機數"; // 摘要說明
    op.Description = "產生指定數量的隨機數"; // 詳細說明
    // 提供參數說明
    var pCount = op.Parameters[0];
    pCount.Description = "隨機數的數量";
    var pRange = op.Parameters[1];
    pRange.Description = "隨機數範圍(0 ~ range-1)";
    return op;
}).WithTags("數學").WithTags("生成函數"); // 指定標籤

// 複雜一點的範例
// 參數及回應皆為自訂型別,檢核失敗回應 HTTP 400 並回傳 ApiError 物件
app.MapPost("workdays", (DateRange range) => {
    if (range.Start > range.End)
    {
        return Results.BadRequest(new ApiError {
            Code = 1487,
            Message = "起始日期不可大於結束日期"            
        });
    }
    var workDays = new List<DateTime>();
    for (var date = range.Start.Date; date <= range.End.Date; date = date.AddDays(1))
    {
        if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
        {
            workDays.Add(date);
        }
    }
    // 傳回 HTTP 200 並回傳 WorkDaysInfo 物件
    return Results.Ok(new WorkDaysInfo
    {
        Start = range.Start,
        End = range.End,
        WorkDays = workDays.ToArray()
    });
})
// 列舉回應的型別
.Produces<WorkDaysInfo>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.WithOpenApi(op => {
    op.OperationId = "GetWorkDays";
    op.Summary = "取得工作日";
    op.Description = "取得指定日期區間內的工作日";
    // 提供參數說明
    op.RequestBody.Description = "日期區間";
    // 提供回應說明
    var r200 = op.Responses["200"];
    r200.Description = "成功取得工作日";
    var r400 = op.Responses["400"];
    r400.Description = "請求失敗";
    return op;
}).WithTags("生成函數");

app.Run();

public class DateRange {
    public DateTime Start { get; set; }
    public DateTime End { get; set; }
}
public class WorkDaysInfo : DateRange {
    public DateTime[] WorkDays { get; set; } = [];    
}

public class ApiError {
    public int Code { get; set;}
    public string Message { get; set; } = string.Empty;
}

就醬,就算是用 Minimal API 寫 WebAPI,照樣能產生有模有樣的 Swagger 介面,半點不馬虎。

另外值得一提是 .NET 7 起加入 TypedResults。主打自動依回傳型別產生 OpenAPI Metadata (可省去 Produces<T>()),並且能在編譯階段檢核回傳型別是否吻合。因此 app.MapPost("workdays",...) 可改寫如下:

// 宣告回應型別共有 Ok<WorkDaysInfo> 及 BadRequest<ApiError> 兩種
app.MapPost("workdays", Results<Ok<WorkDaysInfo>, BadRequest<ApiError>> (DateRange range) => {
    if (range.Start > range.End)
    {
        // 改 TypedRequests.BadRequest
        return TypedResults.BadRequest(new ApiError {
            Code = 1487,
            Message = "起始日期不可大於結束日期"            
        });
    }
    var workDays = new List<DateTime>();
    for (var date = range.Start.Date; date <= range.End.Date; date = date.AddDays(1))
    {
        if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
        {
            workDays.Add(date);
        }
    }
    // 改
    return TypedResults.Ok(new WorkDaysInfo
    {
        Start = range.Start,
        End = range.End,
        WorkDays = workDays.ToArray()
    });
})
// 此處省略 Produces() 回應型別宣告
.WithOpenApi(op => {
    //...
}).WithTags("生成函數");

如此,若回傳型別與宣告不一致,編譯時就會出錯。

第一次在 API 端嘗試 return TypedResults.BadRequest(new ApiError { Code = 1487, Message = "起始日期不可大於結束日期" });,伺服器端會得到 HTTP Status 400,但 Content-Type application/json 且 Response 內容為 JSON 的回應。

試寫了用 JavaScript fetch 接收回應的做法。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Api Test</title>
</head>

<body>
    <div id="app">
        <div>
            <input type="date" v-model="start" placeholder="start date" />
            <input type="date" v-model="end" placeholder="end date" />
            <button @click="getWorkDays">Get Workdays</button>
        </div>
        <div>
            <ul>
                <li v-for="workday in workdays" :key="workday">{{ workday }}</li>
            </ul>
            <div v-if="apiError">
                <p>Error: {{ apiError.code }} - {{ apiError.message }}</p>
            </div>
        </div>
    </div>

    <script src="https://unpkg.com/vue@3"></script>
    <script>
        const start = new Date();
        const end = new Date();
        end.setDate(new Date().getDate() + 7);
        const app = Vue.createApp({
            data() {
                return {
                    start: start.toISOString().split('T')[0],
                    end: end.toISOString().split('T')[0],
                    workdays: [],
                    apiError: null
                }
            },
            methods: {
                async getWorkDays() {
                    this.apiError = null;
                    try {
                        const response = await fetch('workdays', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({ start: this.start, end: this.end })
                        });

                        if (!response.headers.get('content-type')?.includes('application/json')) {
                            throw { code: response.status, message: await response.text() }
                        }

                        const data = await response.json();

                        if (!response.ok) throw data;
                        this.workdays = data.workDays;

                    } catch (error) {
                        this.workdays = [];
                        this.apiError = error.code && error.message ? error : 
                            { code: '?', message: error.toString() };
                    }
                }
            }
        });
        app.mount('#app');
    </script>
</body>

</html>

練習完畢。

範例專案我上傳到 Github 了,想動手玩看看的同學可自取。

Through examples, this article introduces how to enable Minimal API to support OpenAPI and Swagger.


Comments

# by yoyo

.NET 9後 web API template 將不再使用 Swashbuckle.AspNetCore 請問有建議的替代套件,或繼續使用Swashbuckle.AspNetCore嗎? Swashbuckle.AspNetCore is being removed in .NET 9 https://github.com/dotnet/aspnetcore/issues/54599

# by Jeffrey

to yoyo, .NET 9 會有建議的替代方案 (目前看起來 ASP.NET Core 會內建),若沒其他考量,拿香跟著拜就好了。

Post a comment