🍣

Azure Static Web Apps の CLI を使ってローカル開発を試してみた

20 min read

Web サイト作るときに結構 SPA が選択肢として上がることも結構多くなってきてると思います。
とはいっても jQuery とかでごにょごにょいじるだけで十分賄えるサイトとかも多いですが、まぁそれは置いといて今回は Azure の SPA をホスティングすることに特化した Static Web Apps を中心に取り上げていこうと思います!!

Azure Static Web Apps

最初に言っておくと、このサービスは現在プレビューです。でも良いサービスになりそうな雰囲気なのでウォッチしてます。

公式サイトはこちらです。

ドキュメントは以下になります。

Azure Static Web Apps のドキュメント

SPA も結構色々なものが対応出来ていて Angular, React, Vue, Blazor WebAssembly がデプロイできます。その他に Gatsby, Hugo, VuePress, Jekyll, Next.js, Nuxt.js とかを使ってても行けます。
もう 1 つ大きな特徴として Azure Functions で作った Web API もデプロイできます。同じドメインで公開されるので、CORS とか気にしないで叩いたりできるのがお手軽でいいですね。

GitHub や Azure DevOps とかからデプロイが出来て、なおかつ Pull Request を確認するための動作確認用のサイトも自動で作られたりするので至れり尽くせりです。

認証も以下のものに対応しています。

  • Azure Active Directory
  • GitHub
  • Facebook
  • Google
  • Twitter

割と至れり尽くせりです。

ローカル開発対応

Static Web Apps は CLI ツールも提供されています。まだ出たばっかりなのでメインの情報は GitHub になります。

Static Web Apps CLI

以下のコマンドでサクッとインストールできます。

npm install -g @azure/static-web-apps-cli

個人的には C#er なので Blazor WebAssembly で試してみようと思います。

プロジェクトの作成から実行

Visual Studio 2019 を起動して Blazor WebAssembly のプロジェクトを作成します。とりあえず Static Web Apps CLI で動かすことが目的なので、プロジェクトの右クリックメニューから発行でフォルダーにビルド成果物を出力します。

こんな感じのものが出来ます。では Static Web Apps CLI で実行してみましょう。

コマンドプロンプトなどで、このフォルダーに移動して以下のコマンドを打つと起動します!

swa start .

こんな感じのログが出ます。

> swa start .
{
  options: {
    _events: [Object: null prototype] {
      'option:app-location': [Function (anonymous)],
      'option:app-artifact-location': [Function (anonymous)],
      'option:api-location': [Function (anonymous)],
      'option:api-port': [Function (anonymous)],
      'option:host': [Function (anonymous)],
      'option:port': [Function (anonymous)],
      'option:build': [Function (anonymous)]
    },
    _eventsCount: 7,
    _maxListeners: undefined,
    commands: [],
    options: [
      [Option], [Option],
      [Option], [Option],
      [Option], [Option],
      [Option]
    ],
    parent: <ref *1> Command {
      _events: [Object: null prototype],
      _eventsCount: 2,
      _maxListeners: undefined,
      commands: [Array],
      options: [Array],
      parent: null,
      _allowUnknownOption: false,
      _args: [],
      rawArgs: [Array],
      _scriptPath: 'C:\\Users\\k_ota\\AppData\\Roaming\\npm\\node_modules\\@azure\\static-web-apps-cli\\dist\\cli\\index.js',
      _name: 'swa',
      _optionValues: {},
      _storeOptionsAsProperties: true,
      _passCommandToAction: true,
      _actionResults: [Array],
      _actionHandler: null,
      _executableHandler: false,
      _executableFile: null,
      _defaultCommandName: null,
      _exitCallback: null,
      _aliases: [],
      _hidden: false,
      _helpFlags: '-h, --help',
      _helpDescription: 'display help for command',
      _helpShortFlag: '-h',
      _helpLongFlag: '--help',
      _hasImplicitHelpCommand: undefined,
      _helpCommandName: 'help',
      _helpCommandnameAndArgs: 'help [command]',
      _helpCommandDescription: 'display help for command',
      program: [Circular *1],
      Command: [class Command extends EventEmitter],
      Option: [class Option],
      CommanderError: [class CommanderError extends Error],
      _usage: '[options] <command>',
      _version: '0.2.1',
      _versionOptionName: 'version',
      verbose: 'log',
      args: [Array],
      [Symbol(kCapture)]: false
    },
    _allowUnknownOption: false,
    _args: [ [Object] ],
    rawArgs: null,
    _scriptPath: null,
    _name: 'start',
    _optionValues: {},
    _storeOptionsAsProperties: true,
    _passCommandToAction: true,
    _actionResults: [],
    _actionHandler: [Function: listener],
    _executableHandler: false,
    _executableFile: null,
    _defaultCommandName: null,
    _exitCallback: null,
    _aliases: [],
    _hidden: false,
    _helpFlags: '-h, --help',
    _helpDescription: 'display help for command',
    _helpShortFlag: '-h',
    _helpLongFlag: '--help',
    _hasImplicitHelpCommand: 0,
    _helpCommandName: 'help',
    _helpCommandnameAndArgs: 'help [command]',
    _helpCommandDescription: 'display help for command',
    _description: 'start the emulator from a directory or bind to a dev server',
    _argsDescription: undefined,
    appLocation: '.\\',
    appArtifactLocation: '.',
    apiPort: 7071,
    host: '0.0.0.0',
    port: 4280,
    build: false,
    args: [ '.' ],
    verbose: 'log',
    [Symbol(kCapture)]: false
  }
}
[swa]
[swa]
[swa]
[swa] Serving static content:
[swa]     C:\Labs\HelloSWA\HelloSWA\bin\Release\net5.0\browser-wasm\publish\wwwroot
[swa]
[swa]
[swa] Available on:
[swa]     http://192.168.0.140:4280
[swa]     http://0.0.0.0:4280
[swa]
[swa] Azure Static Web Apps emulator started. Press CTRL+C to exit.
[swa]
[swa]

ログに表示されているポートで localhost をブラウザーで開いてみると Blazor WebAssembly のテンプレートで生成されたアプリケーションが表示されます。イイ感じ。

開発サーバーと連携させることも出来ます。swa start コマンドの引数に URL を渡してやれば OK です。私の場合はデバッグ実行したら https://localhost:44318/ で起動したので、以下のようにコマンドをうってやれば OK です。

swa start https://localhost:44318/

このコマンドを実行すると Static Web Apps のローカルエミュレーターがイイ感じに開発用サーバーとやりとりしてくれるようになります。下の図は、左が Static Web Apps CLI で起動したサーバーにつないだもので、右が普通に Visual Studio からデバッグ実行したものになります。

API サーバーも一緒に起動しよう

SPA 部分だけ起動してるだけだと、そんなに嬉しくないので API サーバーも動かしてみたいと思います。Azure Functions のプロジェクトを作って Http トリガーの関数を 1 つ作ります。

API サーバーも一緒に起動するときは swa コマンドに --api=APIのプロジェクトのフォルダー--api=APIサーバーのURL を指定すれば OK です。

とりあえず、Blazor WASM のプロジェクトの Index.razor を少しいじって API を呼ぶようにします。

@page "/"
@inject HttpClient Http

<h1>Hello, world!</h1>

Welcome to your new app.

<button @onclick="InvokeAPI">Invoke API</button>

<p>@Message</p>

@code {
    private string Message { get; set; }

    private async Task InvokeAPI()
    {
        try
        {
            Message = await Http.GetStringAsync("/api/Function1?name=Sample");
        }
        catch (Exception ex)
        {
            Message = ex.ToString();
        }
    }
}

Azure Functions のプロジェクトと Blazor WebAssembly のプロジェクトを両方起動して以下のコマンドをうってみました。

swa start https://localhost:44318 --api=http://localhost:7071

ブラウザーで http://localhost:4280 を開いてボタンを押すと Azure Functions の Http トリガーの関数もサクッと呼べました!CORS の設定いらないのお手軽でいいですね。あと、ローカル開発と本番で呼ぶ先の URL 変えたりするような仕組み入れなくてもいいのも実装面では楽ですね。

構成ファイルを試す

Static Web Apps の構成ファイルの staticwebapp.config.json を書いて見ようと思います。

staticwebapp.config.json は認証の設定やルーティングの設定などを行う設定ファイルです。とりあえず一番よく設定するのは、404 になったりしたときに index.html を表示するようにする設定です。やってみましょう。

まずは、設定ファイルが無い状態で動きを確認します。先ほどやった ASP.NET Core の開発サーバーで起動すると、そこでよしなにやってくれるので、今回は発行メニューからデプロイ用に発行されたフォルダーに対して動作確認をします。

発行先のフォルダーにコマンドプロンプトなどで移動して swa start . --api=http://localhost:7071 でエミュレーターを起動します。Azure Functions 側のプロセスは立ち上げておきましょう。そして http://localhost:4280/counter にアクセスすると以下のように 404 になります。

wwwroot/staticwebapp.config.json を作成して以下のようにします。

staticwebapp.config.json
{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": [ "/css" ]
  }
}

この状態で再度発行して同じことをすると、今度はちゃんとページが表示されます。

ばっちりですね。

認証を試す

認証対応してみましょう!とりあえず App.razor を認証する場合のものに置き換えます。具体的には CascadingAuthenticationState コンポーネントと AuthorizeRouteView コンポーネントを使うように書き換えます。

認証系のコンポーネントが入った Microsoft.AspNetCore.Components.WebAssembly.Authentication を NuGet から追加します。

そして _Imports.razor に必要な using を追加します。

_Imports.razor
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization @* これを追加 *@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using HelloSWA
@using HelloSWA.Shared

App.razor で使う RedirectToLogin.razor を Shared フォルダーに以下の内容で追加します。
元のページにリダイレクトするように post_login_redirect_uri パラメーターも指定しておきます。

RedirectToLogin.razor
@inject NavigationManager NavigationManager

@code {
    protected override void OnInitialized()
    {
        NavigationManager.NavigateTo($"/.auth/login/aad?post_login_redirect_uri={NavigationManager.Uri}", true);
    }
}

そして、 App.razor を編集します。

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p>You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Static Web Apps の認証情報と連携するための AuthenticationStateProvider を実装します。
コードはこちらのライブラリを参考にしました。

https://github.com/anthonychu/blazor-auth-static-web-apps

では実装していきましょう。元のコードはログイン URL をデフォルトから変えたケースなどにも対応していますが、この記事ではその部分は省いています。また、App.razor で context.User.Identity.IsAuthenticated にアクセスするときに NullReferenceException が起きないように、認証されていないケースでも Identity プロパティに値を設定しておきます。

StaticWebAppsAuthenticationStateProvider.cs
using Microsoft.AspNetCore.Components.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Threading.Tasks;

namespace HelloSWA
{
    public class StaticWebAppsAuthenticationStateProvider : AuthenticationStateProvider
    {
        private readonly HttpClient httpClient;

        public StaticWebAppsAuthenticationStateProvider(HttpClient httpClient)
        {
            this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        }

        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            try
            {
                var authenticationData = await this.httpClient.GetFromJsonAsync<AuthenticationData>("/.auth/me");
                authenticationData.ClientPrincipal.UserRoles = authenticationData
                    .ClientPrincipal
                    .UserRoles
                    .Where(x => string.Equals(x, "anonymous", StringComparison.InvariantCultureIgnoreCase))
                    .ToArray();
                if (!authenticationData.ClientPrincipal.UserRoles.Any())
                {
                    // 認証されていないケース
                    return CreateUnauthrizedAuthenticationState();
                }

                var identity = new ClaimsIdentity(authenticationData.ClientPrincipal.IdentityProvider);
                identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, authenticationData.ClientPrincipal.UserId));
                identity.AddClaim(new Claim(ClaimTypes.Name, authenticationData.ClientPrincipal.UserDetails));
                identity.AddClaims(authenticationData.ClientPrincipal.UserRoles
                    .Select(x => new Claim(ClaimTypes.Role, x)));
                return new AuthenticationState(new ClaimsPrincipal(identity));
            }
            catch
            {
                // 認証されていないケース
                return CreateUnauthrizedAuthenticationState();
            }
        }

        private static AuthenticationState CreateUnauthrizedAuthenticationState()
        {
            return new AuthenticationState(new ClaimsPrincipal(
                new ClaimsIdentity()));
        }
    }

    public class AuthenticationData
    {
        public ClientPrincipal ClientPrincipal { get; set; }
    }

    public class ClientPrincipal
    {
        public string IdentityProvider { get; set; }
        public string UserId { get; set; }
        public string UserDetails { get; set; }
        public IEnumerable<string> UserRoles { get; set; }
    }
}

これを使うように Program.csAddAuthorizationCoreAddScopedStaticWebAppsAuthenticationStateProvider を登録しておきます。

Program.cs
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace HelloSWA
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");

            builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
            // 追加
            builder.Services.AddAuthorizationCore()
                .AddScoped<AuthenticationStateProvider, StaticWebAppsAuthenticationStateProvider>();

            await builder.Build().RunAsync();
        }
    }
}

最後に、Index.razor を少しいじってログイン時とログインしていないときで表示を変えてみたいと思います。Blazor でそういうことをしたい場合は AuthorizeView が便利です。

Index.razor
@page "/"
@inject HttpClient Http

<h1>Hello, world!</h1>

Welcome to your new app.

<div>
    <AuthorizeView>
        <Authorized>
            <p>Hello, @context.User.Identity.Name!</p>
            <a href="/.auth/logout">Sign out</a>
        </Authorized>
        <NotAuthorized>
            <a href="/.auth/login/aad">Sign in</a>
        </NotAuthorized>
    </AuthorizeView>
</div>

<button @onclick="InvokeAPI">Invoke API</button>

<p>@Message</p>

@code {
    private string Message { get; set; }

    private async Task InvokeAPI()
    {
        try
        {
            Message = await Http.GetStringAsync("/api/Function1?name=Sample");
        }
        catch (Exception ex)
        {
            Message = ex.ToString();
        }
    }
}

Static Web App CLI 経由で実行してみると…サインインリンクが出ます。

サインインを押すとダミーに認証情報を作る画面に行きます。

ダミーの認証情報を作ると以下のようにちゃんと表示が変わりました。

API も認証で保護してみよう

API の認証については以下のページに説明があります。

Azure Static Web Apps プレビューでのユーザー情報へのアクセス

このドキュメントにユーザーの認証情報を取得するためのコードがあるので、コピペしてプロジェクトに含めます。
ドキュメントのコードは ClaimsPrincipal の Identity が null になってしまうので、ClaimsIdentity も生成するようにしています。変更後のコードは以下のようになります。

StaticWebAppsAuth.cs
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;

namespace HelloSWA.Server
{
    public static class StaticWebAppsAuth
    {
        private class ClientPrincipal
        {
            public string IdentityProvider { get; set; }
            public string UserId { get; set; }
            public string UserDetails { get; set; }
            public IEnumerable<string> UserRoles { get; set; }
        }

        public static ClaimsPrincipal Parse(HttpRequest req)
        {
            var principal = new ClientPrincipal();

            if (req.Headers.TryGetValue("x-ms-client-principal", out var header))
            {
                var data = header[0];
                var decoded = Convert.FromBase64String(data);
                var json = Encoding.ASCII.GetString(decoded);
                principal = JsonSerializer.Deserialize<ClientPrincipal>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
            }

            principal.UserRoles = principal.UserRoles?.Except(new string[] { "anonymous" }, StringComparer.CurrentCultureIgnoreCase);

            if (!principal.UserRoles?.Any() ?? true)
            {
                return new ClaimsPrincipal(new ClaimsIdentity()); // ここを変更
            }

            var identity = new ClaimsIdentity(principal.IdentityProvider);
            identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, principal.UserId));
            identity.AddClaim(new Claim(ClaimTypes.Name, principal.UserDetails));
            identity.AddClaims(principal.UserRoles.Select(r => new Claim(ClaimTypes.Role, r)));

            return new ClaimsPrincipal(identity);
        }
    }
}

関数側のコードを少し変更して、クエリパラメータから取っていた name を Static Web Apps の認証情報から取得するようにします。

Function1.cs
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;

namespace HelloSWA.Server
{
    public static class Function1
    {
        [FunctionName("Function1")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            var principal = StaticWebAppsAuth.Parse(req);
            log.LogInformation("C# HTTP trigger function processed a request.");

            var name = principal.Identity.IsAuthenticated ?
                principal.Identity.Name :
                "Anonymous";
            var responseMessage = $"Hello, {name}. This HTTP triggered function executed successfully.";
            return new OkObjectResult(responseMessage);
        }
    }
}

実行してみましょう。認証情報がないと以下のようになります。

認証情報があると以下のように名前がちゃんと取れています。

まとめ

とりあえず、ざっと Static Web Apps CLI を触って動かしてみました。
Static Web Apps 自体がまだプレビューで Static Web Apps CLI も出てからそんなにたっていないので、今後変わるかもしれませんが、ローカルでもクラウドで動くのとほぼ同じように動かすことが出来るという環境に非常に好感を得ました。

今後も各種機能が追加されていくと思いますので、Static Web Apps は要注目ですね。