Zenn
🕌

チュートリアル: ASP.NET Core を使って最小 API を作成する - F#

2025/03/20に公開

概要

チュートリアル: ASP.NET Core を使って最小 API を作成する のF#版です。
重複する内容については、改変しています。

本ページで作成されるコードは以下に公開しています。
https://github.com/Kuroki-g/kuroki-g-public-zenn-code/tree/main/articles/fsharp-min-web-api

必須コンポーネント

Visual Studio Code で F# を始めるを参照し、Visual Studio CodeでF#を書くための準備をしてください。

API プロジェクトを作成する

次のコマンドを実行します。

dotnet new sln -o TodoApi
code TodoApi

次のディレクトリ構造が生成されます。

.
└── TodoApi
    └── TodoApi.sln

ターミナルで、次のコマンドを実行します。

dotnet new web -lang "F#" -o src/App
dotnet new gitignore -o src/App/

この時点で、次のディレクトリ構造が生成されます。

.
├── TodoApi.sln
└── src
    └── App
        ├── App.fsproj
        ├── Program.fs
        ├── Properties
        │   └── launchSettings.json
        ├── appsettings.Development.json
        └── appsettings.json

dotnet sln add コマンドを使用して、TodoApi ソリューションに App プロジェクトを追加します。

dotnet sln add src/App/App.fsproj 

この時点で一度ビルドを行い、プロジェクトが正しく設定されていることを確認します。
正しく設定されていれば、src/App/binにビルドされたアプリケーションが生成されます。

dotnet build

コードを確認する

src/App/Program.fs ファイルには、次のコードが含まれています。

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapGet("/", Func<string>(fun () -> "Hello World!")) |> ignore

    app.Run()

    0 // Exit code

上記のコードでは次の操作が行われます。

  • 事前に構成された既定値で WebApplicationBuilderWebApplication を作成します。
  • / を返す HTTP GET エンドポイント Hello World! を作成します。

Null許容参照型を設定する

F#のプロジェクトでは、null許容参照型はONになっていません。
C#との相互運用性を高めるために導入します。

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable> <!-- <= add this -->
  </PropertyGroup>

アプリを実行する

次のコマンドを実行し、HTTPS 開発証明書を信頼します。

dotnet dev-certs https --trust

NuGet パッケージを追加する

次のコマンドを実行します。

cd src/App/
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 9.0.3
dotnet add package Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore --version 9.0.3

この時点でビルドを行います。

dotnet build

モデルおよびデータベース コンテキスト クラス

Modelsフォルダーを作成し、次のコードを含む Todo.fs という名前のファイルを作成します。

namespace TodoApi.Models

type Todo() =
    member val Id: int = 0 with get, set
    member val Name: string | null = null with get, set
    member val IsComplete: bool = false with get, set

App.fsprojを開き、コンパイルできるように設定を追加します。
F#ではコンパイラに渡されるファイルの順番が重要なため、Program.fsの上に追加します。

<ItemGroup>
    <Compile Include="Models/*.fs" /> <!-- <= add this -->
    <Compile Include="Program.fs" />
</ItemGroup>

Dataフォルダーを作成し、次のコードのファイルを、TodoDb.fs という名前で作成します。

namespace TodoApi.Data

open Microsoft.EntityFrameworkCore
open TodoApi.Models

type TodoDb(options: DbContextOptions<TodoDb>) =
    inherit DbContext(options)

    member _.Todos = base.Set<Todo>()

Modelsの設定の後に追加します。

<ItemGroup>
    <Compile Include="Models/*.fs" />
    <Compile Include="Data/*.fs" /> <!-- <= Add here! -->
    <Compile Include="Program.fs" />
</ItemGroup>

コンパイルの設定が正しいか確認するため、この時点でビルドを行います。

dotnet build

API コードを追加する

データベース接続の追加

Program.fsファイルの内容を次のコードに置き換えます。

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection // add here
open Microsoft.Extensions.Hosting
open Microsoft.EntityFrameworkCore // add here
open TodoApi.Data // add here

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    
    // add codes from here...
    // AddDbContextのためにMicrosoft.Extensions.DependencyInjectionを追加する。
    builder.Services.AddDbContext<TodoDb>(fun opt -> opt.UseInMemoryDatabase("TodoList") |> ignore)
    |> ignore

    builder.Services.AddDatabaseDeveloperPageExceptionFilter() |> ignore

    // Next codes...
    // let app = builder.Build()

この時点で、インメモリデータベースへの接続設定が追加されます。

APIの追加

APIを追加していきます。

GETの追加

GETリクエストを行うためのAPIのコードを追加します。

open System
open System.Linq // add here
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http // add here
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Microsoft.EntityFrameworkCore
open System.Threading.Tasks // add here
open TodoApi.Data

// ...
    let app = builder.Build()
    // add from here...
    app.MapGet("/todoitems", Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db -> db.Todos.ToListAsync()))
    |> ignore

    app.MapGet(
        "/todoitems/complete",
        Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db ->
            db.Todos.Where(fun t -> t.IsComplete).ToListAsync())
    )
    |> ignore

    app.MapGet(
        "/todoitems/{id}",
        Func<int, TodoDb, Task<IResult>>(fun id db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return Results.NotFound()
                | todo -> return Results.Ok todo
            })
    )
    |> ignore
    
    // next codes...
    app.Run()

F#の書き方については、以下を確認してください。

ここで、実際に動きを確認してみましょう。
開発環境用サーバーを起動してみます。

dotnet run

curlでリクエストをしてみましょう。
データは投入していませんので、空のデータ、もしくは404が返って来ます。

$ curl --fail http://localhost:5058/todoitems
[]
$ curl --fail http://localhost:5058/todoitems/complete
[]
$ curl --fail http://localhost:5058/todoitems/1
curl: (22) The requested URL returned error: 404

残りのAPIの追加

続いて、残りのAPIも加えます。

    // ...
    // next to get api
    app.MapPost(
        "/todoitems",
        Func<Todo, TodoDb, Task<IResult>>(fun todo db ->
            task {
                db.Todos.Add todo |> ignore
                let! result = db.SaveChangesAsync()

                return Results.Created($"/todoitems/{todo.Id}", result)
            })
    )
    |> ignore

    app.MapPut(
        "/todoitems/{id}",
        Func<int, Todo, TodoDb, Task<IResult>>(fun id inputTodo db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return Results.NotFound()
                | todo ->
                    todo.Name <- inputTodo.Name
                    todo.IsComplete <- inputTodo.IsComplete
                    db.SaveChangesAsync() |> ignore
                    return Results.NoContent()
            })
    )
    |> ignore

    app.MapDelete(
        "/todoitems/{id}",
        Func<int, TodoDb, Task<IResult>>(fun id db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return Results.NotFound()
                | todo ->
                    db.Todos.Remove todo |> ignore
                    db.SaveChangesAsync() |> ignore
                    return Results.NoContent()
            })
    )
    |> ignore

    app.Run()

データを投入し、GETで見てみましょう。

$ curl --fail --request POST --header "Content-Type: application/json" --data '{ "Name":"Jone", "IsComplete":true }' http://localhost:5058/todoitems
1
$ curl --fail --request POST --header "Content-Type: application/json" --data '{ "Name":"Bob", "IsComplete":false }' http://localhost:5058/todoitems
1
$ curl -f http://localhost:5058/todoitems
[{"id":1,"name":"Jone","isComplete":true},{"id":2,"name":"Bob","isComplete":false}]
$ curl -f http://localhost:5058/todoitems/complete
[{"id":1,"name":"Jone","isComplete":true}]

データを投入したデータを編集してみましょう。

$ curl --fail --request PUT --header "Content-Ty
pe: application/json" --data '{ "IsComplete":false }' http://localhost:5058/todoitems/1
$ curl -f http://localhost:5058/todoitems/1
{"id":1,"name":null,"isComplete":false}

削除をすると、404となります。

$ curl -f --request DELETE http://localhost:5058
/todoitems/1
$ curl -f http://localhost:5058/todoitems/1
curl: (22) The requested URL returned error: 404

これで、C#の場合と同様に、CURD操作ができることが確かめられました。

Swagger を使って API テスト UI を作成する

Swagger ツールをインストールする

以下のコマンドを実行します。

dotnet add package NSwag.AspNetCore --version 14.2.0

Swagger ミドルウェアを構成する

    builder.Services.AddDatabaseDeveloperPageExceptionFilter() |> ignore
    // ...
    // add codes

    builder.Services.AddEndpointsApiExplorer() |> ignore

    builder.Services.AddOpenApiDocument(fun config ->
        config.DocumentName <- "TodoAPI"
        config.Title <- "TodoAPI v1"
        config.Version <- "v1")
    |> ignore
 
    let app = builder.Build()

    if app.Environment.IsDevelopment() then
        app.UseOpenApi() |> ignore

        app.UseSwaggerUi(fun config ->
            config.DocumentTitle <- "TodoAPI"
            config.Path <- "/swagger"
            config.DocumentPath <- "/swagger/{documentName}/swagger.json"
            config.DocExpansion <- "list")
        |> ignore

Swagger UIでデータの API をテストする

Swagger UIを見てみましょう。
先ほどはHTTPで確認しましたが、HTTPSで起動します。

dotnet run --launch-profile https

ブラウザで<https://localhost:port/swagger/index.html>にアクセスします。
portはsrc/App/Properties/launchSettings.jsonの中のprofiles.https.applicationUrlから確認できます。

Swagger UIでの確認手順については、C#向けの記述を確認してください。

MapGroup API を使う

MapGroup メソッドを使うことで、共通のURLプレフィックスを整理することができます。
todoitemsURLプレフィックスを共通化してみましょう。


    let todoItems = app.MapGroup "/todoitems"

    todoItems.MapGet("/", Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db -> db.Todos.ToListAsync()))
    |> ignore

    // change rest of api

Moduleを使う

Delegate部分をmoduleに切り出すことで、コード全体の見通しを良くすることができます。

module TodoItems =
    let getAllTodos =
        Func<TodoDb, Task<Collections.Generic.List<Todo>>>(fun db -> db.Todos.ToListAsync())

// ...
todoItems.MapGet("/", TodoItems.getAllTodos) |> ignore

TypedResults API を使う

先ほどmoduleへと切り出した関数に対して、TypedResultsを使えるように変更してみます。

module TodoItems =
    let getAllTodos =
        Func<TodoDb, Task<IResult>>(fun db -> task { return db.Todos.ToListAsync() |> TypedResults.Ok :> IResult })

    let getCompleteTodos =
        Func<TodoDb, Task<IResult>>(fun db ->
            task { return db.Todos.Where(fun t -> t.IsComplete).ToListAsync() |> TypedResults.Ok :> IResult })

    let getTodo =
        Func<int, TodoDb, Task<IResult>>(fun id db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return TypedResults.NotFound() :> IResult
                | todo -> return TypedResults.Ok todo :> IResult
            })

    let createTodo =
        Func<Todo, TodoDb, Task<IResult>>(fun todo db ->
            task {
                db.Todos.Add todo |> ignore
                let! result = db.SaveChangesAsync()

                return TypedResults.Created($"/todoitems/{todo.Id}", result) :> IResult
            })

    let updateTodo =
        Func<int, Todo, TodoDb, Task<IResult>>(fun id inputTodo db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return TypedResults.NotFound() :> IResult
                | todo ->
                    todo.Name <- inputTodo.Name
                    todo.IsComplete <- inputTodo.IsComplete
                    db.SaveChangesAsync() |> ignore
                    return TypedResults.NoContent() :> IResult
            })

    let deleteTodo =
        Func<int, TodoDb, Task<IResult>>(fun id db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return TypedResults.NotFound() :> IResult
                | todo ->
                    db.Todos.Remove todo |> ignore
                    db.SaveChangesAsync() |> ignore
                    return TypedResults.NoContent() :> IResult
            })

ここで用いられているキャスト(:>)に関しては、アップキャストを参照してください。

過剰な投稿を防止する

DTOを導入します。
詳細は過剰な投稿を防止するを参照してください。

namespace TodoApi.Dtos

open TodoApi.Models

type TodoItemDTO =
    { Id: int
      Name: string | null
      IsComplete: bool }

    static member Create(v: Todo) =
        { Id = v.Id
          Name = v.Name
          IsComplete = v.IsComplete }

モジュールを以下のように変更します。

module TodoItems =
    let getAllTodos =
        Func<TodoDb, Task<IResult>>(fun db ->
            task { return db.Todos.Select(TodoItemDTO.Create).ToListAsync() |> TypedResults.Ok :> IResult })

    let getCompleteTodos =
        Func<TodoDb, Task<IResult>>(fun db ->
            task {
                return
                    db.Todos.Where(fun t -> t.IsComplete).Select(TodoItemDTO.Create).ToListAsync()
                    |> TypedResults.Ok
                    :> IResult
            })

    let getTodo =
        Func<int, TodoDb, Task<IResult>>(fun id db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return TypedResults.NotFound() :> IResult
                | todo -> return todo |> TodoItemDTO.Create |> TypedResults.Ok :> IResult
            })

    let createTodo =
        Func<TodoItemDTO, TodoDb, Task<IResult>>(fun todoItemDTO db ->
            task {
                let todoItem = new Todo()
                todoItem.Name <- todoItemDTO.Name
                todoItem.IsComplete <- todoItemDTO.IsComplete
                db.Todos.Add todoItem |> ignore
                let! result = db.SaveChangesAsync()

                return TypedResults.Created($"/todoitems/{todoItem.Id}", result) :> IResult
            })

    let updateTodo =
        Func<int, TodoItemDTO, TodoDb, Task<IResult>>(fun id todoItemDTO db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return TypedResults.NotFound() :> IResult
                | todo ->
                    todo.Name <- todoItemDTO.Name
                    todo.IsComplete <- todoItemDTO.IsComplete
                    db.SaveChangesAsync() |> ignore
                    return TypedResults.NoContent() :> IResult
            })

    let deleteTodo =
        Func<int, TodoDb, Task<IResult>>(fun id db ->
            task {
                match! db.Todos.FindAsync id with
                | null -> return TypedResults.NotFound() :> IResult
                | todo ->
                    db.Todos.Remove todo |> ignore
                    db.SaveChangesAsync() |> ignore
                    return TypedResults.NoContent() :> IResult
            })

感想・気になることろ

感想

WEB APIに、C#のコードに沿って書くやり方では、C#でstatic classで定義しているのとあまり変わらない、という印象が強いです。
C#と相互運用するというのは前提条件であるので、letバインド、Option型、Result型、|>、パターンマッチにどれほどの価値を見出すかということになります。
OPPするというC#の書き方を頭の端っこに残しつつ、F#での書き方を強く意識する必要があるでしょう。

本質的ではないところで気になったところ

エディタについて

  • 拡張メソッドでのopenの補完が効かない。System.Linqも手動でインポートが必要。C#での名前空間何だっけな…と調べて手動で記述ということで非効率です。
  • 暗黙的なアップキャストが貧弱に感じます。IResultに対して入れないとコンパイルが通らないというのは煩雑です。
  • C#資産を使えないと片手落ちにもかかわらず、null許容参照型がまだまだ弱いです。
    • ただ、.NET 10 LTSでもnull許容参照型は維持されれば、導入は広まるでしょう。
    • null 安全はデフォルトでオンにしておいて欲しいところです。
  • 公式のドキュメントが少なすぎる。
    • Microsoftのドキュメントで、明らかにF#のドキュメントの量が少なく、新しく学ぶ人へのサポートが貧弱です。
    • そもそも、F#単体で完結させるやり方をするのが基本なのかもしれません。

コミュニティの状況

ランキング[1][2]でも、関数型プログラミング言語の使用率はかなり低いものになっています。
会社が Scala[3][4]、Elixir[5]、Clojure[4:1]を使っているというものでもない限り使うことはないでしょう。F#もひっそり使っている方がいらっしゃるよう[6]です。
コミュニティ層の薄さというのは、行き詰ったときの情報源の少なさに直結します。

新しく学ぶ人へのサポートが貧弱です。Why F#?というのをもっと強く推さないと、使用人口は増えないように思えます。Wikipediaにも利用実績書いてないし[7]

  • Erlangだと分散処理だよね、といったものがないです。
  • Microsoftの機械学習ライブラリSynapseMLはScalaで開発されており、「Many of us feel that scala is the queen of the languages so we almost exclusively code in scala.」と述べています[8]

これくらい言えるとおっ?となりますね。

利用事例

実際の利用についてですが、Deep Researchに探してもらい見つけてもらいました。

もう少し推せばいいのに…と思いますね。

参考文献

脚注
  1. The Top Programming Languages 2024 ↩︎

  2. TIOBE Index for March 2025 ↩︎

  3. Scala先駆者インタビュー VOL.7 水島さん(株式会社ドワンゴ/一般社団法人Japan Scala Association) ↩︎

  4. JavaからScala、そしてClojureへ: 実務で活きる関数型プログラミング ↩︎ ↩︎

  5. モダン Erlang/OTP ↩︎

  6. 仕事と F# と私 ↩︎

  7. F Sharp (programming language) ↩︎

  8. Microsoft Announces General Availability of SynapseML ↩︎

  9. Where F# Outshines Other Languages: A Deep Dive with Use Cases ↩︎

  10. Case Study: Writing Microservices with F# ↩︎

  11. Credit Suisse quants were the first users of a new programming language ↩︎

GitHubで編集を提案

Discussion

ログインするとコメントできます