WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Web API にファイルアップロード

by WebSurfer 2023年3月9日 10:23

.NET Framework 版の ASP.NET Web API はファイルアップロードの受信に対応してません。正確に言うと multipart/form-data 形式で送信されてきたデータのフォーマッターが実装されていません。

Web API にファイルアップロード

なので、アクションメソッドの引数を Post(HttpPostedFileBase file) というようして、それに multipart/form-data 形式でファイルを送信すると UnsupportedMediaTypeException がスローされ、

"メディアの型 'multipart/form-data' のコンテンツから型 'HttpPostedFileBase' のオブジェクトを読み取るために使用可能な MediaTypeFormatter がありません。"

・・・というエラーになります。

その対処方法を書きます。基本的には Microsoft のドキュメント「ASP.NET Web APIでの HTML フォーム データの送信: ファイルのアップロードとマルチパート MIME」に書いてあった方法です。

例えば以下のような View で HTML5 fetch API を使ってファイルをアップロードするとします。

<form id="form1" method="post" enctype="multipart/form-data">
    <input name="caption" type="text" value="Summer Vacation" />

    <input type="file" name="file" />
</form>

<button type="button" id="button1" class="btn btn-primary">
    アップロード
</button>

<div id="result1"></div>

@section Scripts {
    <script type="text/javascript">
        let resultDiv, uploadButton;

        window.addEventListener('DOMContentLoaded', () => {
            uploadButton = document.getElementById("button1");
            resultDiv = document.getElementById("result1");
            uploadButton.addEventListener('click', uploadFile);
        });

        const uploadFile = async () => {
            let fd = new FormData(document.getElementById("form1"));
            const param = {
                method: "POST",
                body: fd
            }
            const response = await fetch("/api/FileUpload", param);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message;
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        };
    </script>
}

送信されるデータは以下のように multipart/form-data 形式になります。

------WebKitFormBoundaryoasm5HIwy0wijTSo
Content-Disposition: form-data; name="caption"

Summer Vacation
------WebKitFormBoundaryoasm5HIwy0wijTSo
Content-Disposition: form-data; name="file"; filename="0305Result.jpg"
Content-Type: image/jpeg

それを .NET Framework 版の ASP.NET Web API のアクションメソッドで取得してサーバーのフォルダにファイルとして書き込むコードを書いてみました。以下の通りです。

説明はコメントに書きましたのでそれを見てください。手抜きでスミマセン。

using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Web;
using System.Web.Http;
using System.Threading.Tasks;
using System.Collections.Specialized;
using System.Text;
using System.Net.Http.Headers;

namespace WebAPI.Controllers
{
    public class FileUploadController : ApiController
    {
        // アクションメソッドは引数無しにする。フォーマッタを呼び出さ
        // ずアクションメソッド内で要求本文を処理するため
        public async Task<HttpResponseMessage> Post()
        {
            // 要求にマルチパート MIME メッセージが含まれているか確認
            if (!Request.Content.IsMimeMultipartContent())
            {
                throw new HttpResponseException(
                               HttpStatusCode.UnsupportedMediaType);
            }

            // アプリケーションルート直下に UploadedFiles というフォル
            // ダを設け、そこにアップロードされてきたファイルを書き込
            // む。以下のコードでそのフォルダの物理パスを取得
            string root = HttpContext.Current.Server
                                     .MapPath("~/UploadedFiles");

            // アップロードされたファイルにファイルストリームを割り当て
            var provider = new MultipartFormDataStreamProvider(root);

            // マルチパート MIME メッセージのすべてのファイル部分を
            // 抽出し、上の root で指定されたフォルダに書き込む。
            // ファイル名は ReadAsMultipartAsync が自動的に一意に
            // なるように生成する
            await Request.Content.ReadAsMultipartAsync(provider);

            // ReadAsMultipartAsync メソッドが完了すると、FileData
            // プロパティで MultipartFileData オブジェクトのコレ
            // クションを取得できる。MultipartFileData オブジェクト
            // からファイルに関する情報を取得できる
            foreach (MultipartFileData file in provider.FileData)
            {
                // ReadAsMultipartAsync メソッドが自動的に生成した
                // ファイル名をフルパスで取得。UploadedFiles フォル
                // ダにはその名前でアップロードされてきたファイルが
                // 書き込まれている
                string source = file.LocalFileName;

                // 要求ヘッダに含まれるファイル名を取得。取得した
                // 文字列は何故か " で囲われるのでそれを除去
                var name = file.Headers.ContentDisposition
                                       .FileName.Replace("\"", "");
                string dist = root + "\\" + name;

                // ReadAsMultipartAsync メソッドが生成したファイル
                // 名を要求ヘッダに含まれるファイル名に書き換える。
                // 同名のファイルが存在する場合は先に削除する
                File.Delete(dist);
                File.Move(source, dist);
            }

            // <input name="caption" type="text" /> で送信されて
            // きたデータは以下のようにして取得できる
            NameValueCollection col = provider.FormData;
            foreach (string s in col.AllKeys)
            {
                Trace.WriteLine($"Key: {s}, Value: {col[s]}");
            }

            string result = "アップロード完了";

            // .NET Framework 版の Web API は JSON を返すのが基本
            // らしく、アクションメソッドの戻り値の型を string にし
            // て return result; とすると送信される文字列は " で囲
            // われてしまうので以下のようにする
            return new HttpResponseMessage
            {
                Content = new StringContent(result,
                                            Encoding.UTF8,
                                            "text/plain")
            };
        }
    }
}

何か見落としがあるような気もしますが、自分が検証した限り期待通りに動きました。

ちなみに、ASP.NET MVC5 および .NET 6.0 で作った ASP.NET Core Web API では multipart/form-data 形式に対応するフォーマッターが組み込まれているので、上記のようなことをする必要はありません。そちらの方向に進んだ方が正解かもしれません。

Tags: ,

Web API

Web API に CORS 実装 (CORE)

by WebSurfer 2023年1月21日 22:50

ASP.NET Core Web API に CORS を実装してみました。下の画像は検証のため別プロジェクトの MVC アプリから fetch API を使って Web API に要求を出してデータを取得し表示したものです。

Web API に CORS 実装

開発環境の IIS Express で動かしているのでホストは同じ localhost ですが、ポートが異なるので要��はクロスドメインになります。上の画像では fetch API のメソッドが PUT なので、ブラウザがまずプリフライトリクエストを出し、その応答を見て要求を出してデータを取得しています。

ASP.NET Core の場合はフレームワーク組み込みの CORS 用のミドルウェアが用意されていて、それを Microsoft のドキュメント「ASP.NET Core でクロスオリジン要求 (CORS) を有効にする」に従って有効にします。

(ちなみに、.NET Framework 版の ASP.NET Web アプリでは、先の記事「クロスドメインの WCF サービス」で書いたように自力で実装していました)

具体的には、Visual Studio 2022 のテンプレートでフレームワーク .NET 6.0 で作った ASP.NET Web API アプリであれば、上に紹介した Microsoft のドキュメントの「名前付きポリシーとミドルウェアを使用した CORS」のセクションに従って Program.cs に以下のコードを追加するだけで CORS は有効になります。

var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
builder.Services.AddCors(options =>
{
    options.AddPolicy(name: MyAllowSpecificOrigins,
        policy =>
        {
            policy.WithOrigins("https://localhost:44343")
                  .AllowAnyHeader()
                  .AllowAnyMethod();
        });
});

// ・・・中略・・・

app.UseCors(MyAllowSpecificOrigins);

「名前付き」にすると、[EnableCors("{Policy String}")] 属性をコントローラーに付与しないと CORS は有効にならないと思っていたがそうではなかったです。コントローラーには何も付与しなくても上の設定だけで CORS は有効になります。

WithOrigins メソッドに設定した https://localhost:44343 は CORS でのアクセスを許可する要求元です。すべての要求元を許可する AllowAnyOrigin メソッドもあります。詳しくは Microsoft のドキュメント「CorsPolicyBuilder クラス」を見てください。

AllowAnyHeader メソッド、AllowAnyMethod メソッドはプリフライトが必要になる場合は必要です(Any ではなく特定の Header, Method を指定することもできます)。それらが無いとプリフライトの応答ヘッダに、

Access-Control-Allow-Headers
Access-Control-Allow-Methods

・・・が含まれないのでプリフライトの後の要求がブラウザから出ず失敗します。

上の画像のプリフライトリクエストの要求・応答ヘッダを Fiddler でキャプチャした画像を下に貼っておきます。

プリフライトリクエスト

要求側はブラウザの仕事で開発者は何もする必要はありません。プリフライトが必要か否かもブラウザが判断して CORS に必要な要求を出してくれます。

Response Headers の Security の項目は、要求を受けてサーバー側のミドルウェア(上のコード参照)が設定したものです。

参考に検証に使ったコードを下に載せておきます。Windows 10 22H2 の Edge 109.0.1518.61, Chrome 109.0.5414.75, Firefox 109.0, Opera 94.0.4606.65 で確認しました。

Web API

using Microsoft.AspNetCore.Mvc;

namespace WebApi.Controllers
{
    public class Hero
    {
        public int Id { get; set; }
        public string? Name { get; set; }
    }

    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        private List<Hero> heroes = new List<Hero> {
              new Hero {Id = 1, Name = "スーパーマン"},
              new Hero {Id = 2, Name = "バットマン"},
              new Hero {Id = 3, Name = "ウェブマトリクスマン"},
              new Hero {Id = 4, Name = "チャッカマン"},
              new Hero {Id = 5, Name = "スライムマン"}
        };

        // GET: api/values (Read...すべてのレコードを取得)
        [HttpGet]
        public List<Hero> Get()
        {
            return heroes;
        }

        // GET api/values/5 (Read...id 指定のレコード取得)
        [HttpGet("{id}")]
        public Hero Get(int id)
        {
            return heroes[id - 1];
        }

        // POST api/values (Create...レコード追加)
        [HttpPost]
        public List<Hero> Post([FromBody] Hero postedHero)
        {
            heroes.Add(postedHero);
            return heroes;
        }

        // PUT api/values/5 (Update...id 指定のレコード更新)
        [HttpPut("{id}")]
        public List<Hero> Put(int id, [FromBody] Hero postedHero)
        {
            heroes[id - 1].Name = postedHero.Name;
            return heroes;
        }

        // DELETE api/values/5 (Delete...id 指定のレコード削除)
        [HttpDelete("{id}")]
        public List<Hero> Delete(int id)
        {
            heroes.RemoveAt(id - 1);
            return heroes;
        }
    }
}

クライアント側 (MVC の View)

@{
    ViewData["Title"] = "ApiCors";
}

<h1>ApiCors</h1>

<input type="button" value="READ ALL" onclick="apiHeroesGet();" />
<input type="button" value="READ 5" onclick="apiHeroesGet5();" />
<input type="button" value="UPDATE 5" onclick="apiHeroesPut5();" />
<input type="button" value="DELETE 5" onclick="apiHeroesDelete5();" />
<input type="button" value="CREATE 6" onclick="apiHeroesPost();" />

<ul id="heroes"></ul>

@section Scripts {
    <script type="text/javascript">
        //<![CDATA[
        const url = "https://localhost:44371/api/values"; // IIS Express
        //const url = "https://localhost:7216/api/values";    //Kestrel
        const elem = document.querySelector("#heroes");

        const apiHeroesGet = async () => {
            const response = await fetch(url);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++) {
                    elem.insertAdjacentHTML("beforeend", 
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesGet5 = async () => {
            const response = await fetch(url + "/5");
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                elem.insertAdjacentHTML("beforeend",
                    `<li>${data.id}: ${data.name}</li>`);
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesPut5 = async () => {
            const params = {
                method: "PUT",
                body: '{"Id":5,"Name":"Updated Hero"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch(url + "/5", params);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++) {
                    elem.insertAdjacentHTML("beforeend",
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesDelete5 = async () => {
            const params = {
                method: "DELETE"
            }
            const response = await fetch(url + "/5", params);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++) {
                    elem.insertAdjacentHTML("beforeend",
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesPost = async () => {
            const params = {
                method: "POST",
                body: '{"Id":6,"Name":"Created Hero"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch(url, params);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++)
                {
                    elem.insertAdjacentHTML("beforeend", 
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        //]]>
    </script>
}

本題の CORS の実装とは直接関係ないことですが、予想外のことがあったのでそれを書いておきます。

PUT については Web API 側のアクションメソッドの引数に int id が含まれているので、要求 URL に api/values/5 というように id を渡さないと 405 Method Not Allowed、Allow: GET, POST となって失敗します。

何故それで Method Not Allowed となるのか、GET, POST でないとダメと言われるのかは分かりませんが・・・

Tags: ,

Web API

IdentityServer で Web API の認証を サポート

by WebSurfer 2022年4月8日 16:48

先の記事「Duende IdentityServer」で作成した認証サーバーを利用して ASP.NET Core Web API アプリのユーザー認証ができるようにしてみます。

Duende Software のドキュメント Protecting an API using Client Credentials に例が載っていますが、その記事では Machine to Machine で(即ち、個々のユーザー認証なしで)トークンを取得しているところを、IdentityServer に登録済みのユーザーの id と password で認証を受けてトークンを取得するように変更しました。

個々のユーザーのクレデンシャルを使うのは望ましくないというような意味のことがドキュメントに書いてあった気がしますが、せっかく苦労してサンプルを作ったので以下にその手順を書いておきます。

(1) Web API プロジェクトの作成

Visual Studio 2022 の「ASP.NET Core Web API」のテンプレートを使って[フレームワーク(F)]を「.NET 6.0 (長期的なサポート)」とし[認証の種類(A)]を「なし」にして Web API プロジェクトを作成します。

Web API プロジェクトの作成

(2) NuGet パッケージのインストール

Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。

NuGet パッケージのインストール

(3) JWT 認証スキーマを登録

Web API プロジェクトに含まれる Program.cs のコードを編集して JWT 認証スキーマを登録します。Duende Software のドキュメントの Adding an APIAuthorization at the API を参考にしました。

.NET 6.0 のプロジェクトの Program.cs では以下のようになります。自動生成されたコードに「// 追加」とコメントしたコードを追加します。

// 追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

// 追加
builder.Services.AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.Authority = "https://localhost:5001";
            options.TokenValidationParameters = 
                new TokenValidationParameters
                {
                    ValidateAudience = false
                };
        });

// 追加
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ApiScope", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("scope", "scope3");
    });
});

// ・・・中略・・・

// 追加
app.UseAuthentication();

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

上のコードで、options.Authority に設定する URL は先に作成した Duende IdentityServer の URL に合わせてください。デフォルトでは上のように "https://localhost:5001" となっているはずです。

policy.RequireClaim("scope", "scope3") の "scope3" は後で Duende IdentityServer プロジェクトの Config.cs ファイルで設定します。下のステップ (5) のコードを見てください。

(4) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() ��ソッドに [Authorize] 属性を付与します。using Microsoft.AspNetCore.Authorization; の追加を忘れないようにしてください。

ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずですので試してみてください。

(5) IdentityServer に Client を追加

先に作成済の Duende IdentityServer プロジェクトの Config.cs ファイルを開いて上の Web API アプリの認証をサポートするための Client を追加します。以下の「// 追加」とコメントしたコードを既存のファイルに追加します。

using Duende.IdentityServer.Models;

namespace DuendeIdentityServer
{
    public static class Config
    {

        // ・・・中略・・・

        public static IEnumerable<ApiScope> ApiScopes => new ApiScope[] {
            new ApiScope("scope1"),
            new ApiScope("scope2"),
            
            // 追加
            new ApiScope("scope3")
        };

        public static IEnumerable<Client> Clients => new Client[] {

            // ・・・中略・・・
            
            // 追加
            new Client
            {                
                ClientId = "WebApiNet6",
                ClientSecrets = { new Secret("0C86E143-30E0-4FB4-8710-008CD861BF5B".Sha256()) },

                AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                AllowedScopes = { "scope3" }
            }
        };
    }
}

Duende Software のドキュメントの例では Machine to Machine communication でトークンを取得していますが、そこを Issuing Tokens based on User PasswordsToken Endpoint を参考に id と password を送信してトークンを取得できるように変更しました。AllowedGrantTypes を GrantTypes.ResourceOwnerPassword に設定するのがキモらしいです。

(6) クライアントアプリの作成

IdentityServer に登録済みのユーザーの id と password を送信してトークンを取得し、そのトークンを要求ヘッダに設定して Web API の WeatherForecast アクションメソッドを GET 要求して結果を表示する検証用のアプリを作成します。

Duende Software のドキュメント Creating the client を参考に .NET 6.0 のコンソールアプリとして作成しました。

コードは以下の通りです。NuGet で IdentityModel をインストールしないと動かないので注意してください。

using IdentityModel.Client;
using System.Text.Json;

var client = new HttpClient();

var disco = await client
    .GetDiscoveryDocumentAsync("https://localhost:5001");

if (disco.IsError)
{
    Console.WriteLine(disco.Error);
    return;
}

var tokenResponse2 = await client.RequestPasswordTokenAsync(
    new PasswordTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "WebApiNet6",
        ClientSecret = "0C86E143-30E0-4FB4-8710-008CD861BF5B",

        Scope = "scope3",

        UserName = "alice",
        Password = "Pass123$"
    });

if (tokenResponse2.IsError)
{
    Console.WriteLine(tokenResponse2.Error);
    return;
}
else
{
    Console.WriteLine(tokenResponse2.Json);
    Console.WriteLine("\n\n");
}

client.Dispose();

var apiClient = new HttpClient();
apiClient.SetBearerToken(tokenResponse2.AccessToken);

var response = await apiClient
              .GetAsync("https://localhost:44300/WeatherForecast");

if (!response.IsSuccessStatusCode)
{
    Console.WriteLine(response.StatusCode);
}
else
{
    var doc = JsonDocument
              .Parse(await response.Content.ReadAsStringAsync())
              .RootElement;
    Console.WriteLine(JsonSerializer
          .Serialize(doc, 
              new JsonSerializerOptions { WriteIndented = true }));
}

apiClient.Dispose();

コンソールアプリの実行結果は以下のようになります。事前に IdentityServer と Web API を動かしておく必要がありますので注意してください。

コンソールアプリの実行結果

一応期待通り動くことは検証できましたが、ホントに上記の設定で良いのかは自信がありません。ひょっとしたら、やってはいけないことをやっているのかもしれませんので、コピペして使うのは避けた方が良いと思います。(笑)

Tags: , ,

Authentication

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar