2

打造支援 OpenAPI 標準的 Minimal API

 1 month ago
source link: https://blog.darkthread.net/blog/min-api-openapi/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

打造支援 OpenAPI 標準的 Minimal API-黑暗執行緒

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 介面,半點不馬虎。

Fig1_638493947264944342.png

Fig2_638493947266647554.png

Fig3_638493947268323627.png

另外值得一提是 .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("生成函數");

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

Fig4_638493947269950563.png

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

Fig5_638493947271628779.png

試寫了用 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>

Fig6_638493947274812450.gif

練習完畢。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK