狛ログ

2022年5月23日月曜日

【Red Hat Linux 8.2】ActiveDirectoryユーザでWebアプリのBASIC認証しようとしたらハマった話。

技術部のyuckieee(ゆっきー)です!
最近、RedHatLinux上でWebアプリ(Web/APサーバ設定含む)を行ったのですが、その際にハマったエラーの解決策をメモしておきます。ググっても、RedHatLinux公式のカスタマーポータル等で探しても、中々それらしき記述がなく、めちゃくちゃ時間を潰しました。

■発生事象

今回は、外部ActiveDirecotry上に設定されたID/パスワードを使用して、WebアプリのBASIC認証を行う形をとっていました。
ですが、何度認証を行ってもBASIC認証ダイアログが出続けログインが出来ません。
SSSDのログには、BASIC認証を行ったタイミングで以下のようなエラーが表示されていました。
March 07 00:00:00 httpd [999999]: pam_sss(webapp:auth): authentication success; logname= uid=0 euid=0 tty= ruser= rhost= user=user_name
March 07 00:00:00 httpd [999999]: pam_sss(webapp:account): Access denied for user user_name: 6 (Permission denied)
内容を見ると、認証は成功するのに該当ユーザの権限が無いためにエラーとなっているように見えます。ただ、ActiveDirectoryにはユーザは間違いなく登録されてており、該当サーバへのアクセスが許可されたグループに所属、現にSSHでは接続できていました。謎です。

■発生環境

[構成]
 OS:RedHatLinux 8.2
 Web/Apサーバ:Apache 2.4.xx
  - BASIC認証のため、mod_authnz_pamモジュール使用
  - WebアプリはPython3.xを使用して構築(Apacheと連携させるため、mod_httpdを使用)
 認証:PAM
 ActiveDirecotry連携:SSSD 2.x

具体的なBASIC認証の設定は以下サイトを参考に行いました。
[参考]Apache module mod_authnz_pam
※pamの設定のみ以下のように変更して、ローカルユーザでの認証もできるようにしていました。
#%PAM-1.0
auth    include system-auth
account include system-auth
session include system-auth

■事象分析

エラー内容より「pam_sssによるwebapp(参考サイトでtlwikiとなっていたPAMサービス名)のaccountチェックにおいてアクセス権限エラーが出ている」と言うことは分かります。
そのため、pam_sssaccountを呼び出している箇所をコメントアウトしてみました。
/etc/pam.d/system-auth
#account     [default=bad success=ok user_unknown=ignore] pam_sss.so ←こちらコメントアウト

まさかですが、これでBASIC認証自体は可能となりました。

同じpam_sssを使用しているであろうSSH接続では発生していないことから、Webアプリ限定のエラーだと分かります。 ちなみにaccountは以下の役割を果たしているそうです。(10.2. PAM 設定ファイルについて)
account: このモジュールインターフェースは、アクセスが許可されることを確認します。
たとえば、ユーザーアカウントの有効期限が切れたか、または特定の時間にユーザーがログインできるかどうかを確認します。
アクセス許可の確認と書いてあるとおり、コメントアウトすることでGPOベースのアクセス制御が外れた状態になることが分かりました。
というか、このGPOベースのアクセス制御の設定に問題が有りました。

■発生原因

SSSDのバージョンアップに際し、セキュリティ向上の観点からデフォルトでActiveDirectoryのGPOベースのアクセス制御が有効となったようです。
さらに、本アクセス制御対象はデフォルト以外のPAMサービスがデフォルトで「拒否」状態となっており、デフォルトではない「Webアプリ用PAMサービス」は「拒否」となり、GPOベースのアクセス制御自体が許可されていない状態でした。
エラーメッセージは、ユーザではなく、PAMサービスに対する権限エラーだったのですね。

[参考]
Cannot log in to ClearCase WAN server after enabling SSSD-based authentication to Windows Active Directory on Red Hat Linux 8.x.
2.6.3. SSSD の GPO ベースのアクセス制御の設定

■解決方法

解決方法としては以下2パターンが考えられます。
①GPOベースのアクセス制御時、デフォルト以外のPAMサービスの扱いを「拒否」から「許可」に設定変更(=記載追加)する。
以下のとおりsssd.confのADドメインのセクションに記載を追加し、SSSDサービスを再起動することで対応。
/etc/sssd/sssd.conf
ad_gpo_default_right=permit

②GPOベースのアクセス制御時、作成したPAMサービスを「許可」するよう設定追加する。
以下のとおりsssd.confのADドメインのセクションに記載を追加し、SSSDサービスを再起動することで対応。
/etc/sssd/sssd.conf
ad_gpo_map_interactive = + webapp

解決策として①だと影響範囲が大きそうでしたので②を採用しました。

■まとめ

今回、ActiveDirectoryやSSSD設定が自分の担当ではなかったことで原因調査がかなり難航しました。
振り返りとしては、早期にSSSDのデバッグログ出力設定変更などの相談をしておくと良かったのかなとも思いましたが、原因箇所が特定出来ない中では中々難しいですね。

個人的には良い経験となったと思いますので、この内容を今後に活かしていければ良いなと考えています。

2022年5月16日月曜日

Angular ユニットテスト エラー(NgRxのReducerで発生する「TypeError: Cannot read properties of undefined (reading 'xxxxx')」)の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回前々回に引き続き、Angularでユニットテスト作成する時のエラー対応です。
今回はNgRx関連のエラーです。
error properties: Object({ longStack: 'TypeError: Cannot read properties of undefined (reading 'xxxxx')
      at http://localhost:9876/_karma_webpack_/webpack:/src/app/koma/store/reducers/index.ts:xx:xx
みたいなエラーが出てしまった場合の対応です。

・・・・ええ、言いたいことは分かります。
undefinedって出ているんだから、該当箇所見ればすぐ分かるじゃん。
って事ですよね?

まあ、仰る通りなのですが、エラーとなっている箇所は、コンポーネント側ではなく、NgRxのReducer(index.ts)で、エラーも微妙な位置なので、どう修正すれば良いのか、分からないんですよね・・・・。

ちなみに、コンポーネント側は、
[koma-confirm.component.ts]
export class KomaConfirmComponent implements OnInit, OnDestroy {
  komaConfirmSubscription: Subscription = new Subscription();
  saveData: KomaViewSaveModel;

  constructor(
    private router: Router,
    public store: Store<fromKoma.State>
  ) {}

  ngOnInit(): void {
    // 画面間保持項目の退避
    this.komaConfirmSubscription.add(
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
        .subscribe(model => {
          if (model) {
            this.saveData = model;
          } else {
            this.router.navigateByUrl('/koma/home');
          }
        }),
    );
  }

  ngOnDestroy(): void {
    this.komaConfirmSubscription.unsubscribe();
  }
}
となっています。

良くある登録系機能(入力→確認→完了)の「確認画面」を想定して頂くと分かりやすいです。
入力画面で入力された値をStoreに格納し、それを確認画面(のngOnInit)で取得しようとしています。
もし、Storeからデータを取得出来ない場合、不正な遷移を行ったと見做し、ホーム画面に遷移させています。

なので、
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
が何となく怪しいのは分かるのですが、Reducer(index.ts)側は、

[/src/app/koma/store/reducers/index.ts]
export const getKomaState = createSelector(
  getKomaFeatureState,
  (state: KomaFeatureState) => state.koma, // ←ここがエラーになってる。
);

export const getDataRegisterPost = createSelector(
  getKomaState,
  fromKoma.getDataRegisterPost, // ←せめてここでエラーになっていれば・・・・
);
上記の「state.koma」の部分がエラーとなっていて、内容としては、
TypeError: Cannot read properties of undefined (reading 'koma')
と出ています。

せめて、
  fromKoma.getDataRegisterPost,
の部分でエラーになっていれば、エラー原因の当たりがすぐにつくのですが・・・・

では、このエラーの対応をしていきたいと思います。

テストファイルの方を見ていきましょう。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
DIの設定だけはしている感じですね。

前回は、「StoreのdispatchでActionの実行をテストする」だったので、特に意識しなかったのですが、
Storeからデータを取得する、という場合は、「selector」と、取得するデータを定義する必要があります。

[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore({
          selectors: [
            { selector: fromKoma.getDataRegisterPost,
              value: {
                komaId: '1',
                komaData: '1111',
              }
            },
          ]
        }),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

provideMockStoreの定義に引数として、selectorとvalueを設定しています。

これで、エラー(TypeError: Cannot read properties of undefined (reading 'xxxxx'))は出なくなると思います。

では、ちゃんとテストケースを追加していこうと思います。

まず、書き換えたいのが、
provideMockStoreの定義に引数として、selectorとvalueを設定している部分です。
今、そこを追加したのに・・・変えるんかい!って感じですね。

後々、テストケースが追加になった時に、selector内の値の入れ替えを楽にしたいので、
provideMockStoreの引数で設定するのではなく、テストクラス内で使えるように変えていこうと思います。
テストケースも追加していきます。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';
import { KomaViewSaveModel } from '../../models/koma';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let store: MockStore<fromKoma.State>;
  let router: Router;
  let mockDataRegisterPostSelector;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    // データの初期値を設定
    mockDataRegisterPostSelector = store.overrideSelector(
      fromAccount.getDataRegisterPost, {
        komaId: '1',
        komaData: '1111'
      }
    );

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ngOnInitのテスト', () => {
    it('画面引き継ぎ情報が無い場合、ホーム画面へ遷移すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データにnullを設定
      mockDataRegisterPostSelector.setResult(null);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // navigateByUrlが、'/koma/home'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/home');
    });

    it('画面引き継ぎ情報がある場合、データを退避すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データを再設定
      const formModel: KomaViewSaveModel = {
        komaId: '2',
        komaData: '2222'
      };
      mockDataRegisterPostSelector.setResult(formModel);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // 退避されたデータの中身をチェック
      expect(component.saveData).not.toBeUndefined();
      expect(component.saveData).not.toBeNull();
      expect(component.saveData.komaId).toBe('2');
      expect(component.saveData.komaData).toBe('2222');

      // navigateByUrlが、呼ばれていないか確認
      expect(navigateByUrlSpy).not.toHaveBeenCalled();
    });
  });
});

ポイントとしては、「overrideSelector」によって、selectorを再定義している部分
    mockDataRegisterPostSelector = store.overrideSelector(
        // (中略)
    );
と、値を再設定する
    mockDataRegisterPostSelector.setResult(formModel);
の部分ですね。

これで、Store内のデータを設定し、テストをすることが可能になりました!

今後も、Angularでユニットテストについて、ブログに書いて行こうと思います。

2022年5月13日金曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Store )の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回に引き続き、Angularでユニットテスト作成時のエラー対応です。
今回もDI関連のエラーです。
NullInjectorError: R3InjectorError(DynamicTestModule)[Store -> Store]: 
  NullInjectorError: No provider for Store!
上記のエラーが出てしまった場合の対応です。

これは、NgRxのStoreを使用している場合に出てくるエラーとなります。
NgRxの公式サイトを見ると、コンポーネント側のテスト記載のサンプルがあるので、詳細はそちらを見るとして、とりあえず、エラーを無くしてみます。
まずは、コンポーネント側のロジックを確認してみましょう。

例として、よくある「入力画面(から確認画面)」を挙げてみます。
「入力画面で、入力した値をSubmitによって、Storeに保存し、確認画面へ引き継ぐ」って感じですね。
[input.component.ts]
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
// (中略)
  constructor(
    private router: Router,
    public store: Store<fromInput.State>,
  ) {}
// (中略)
  onSubmit(inputData: InputViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)確認画面へ遷移
    this.store.dispatch(InputActions.setRegisterPostData({ data: inputData }));
    this.router.navigateByUrl('/koma/confirm');
  }

ここではNgRxの仕様については触れませんので、NgRxを知っている前提で進めます。
それと、前回やった「Router」もありますので、一緒にエラーが出ないように対応してみます。

まずは必要モジュールのインポートと、DIをします。
(Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。)
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
と言う感じになります。

Storeに関する部分は、
import { provideMockStore } from '@ngrx/store/testing';
と、
      providers: [provideMockStore()],
を追加しています。
先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[input.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromInput from '../../store/reducers';
import * as InputActions from '../../store/actions/input.actions';
import { InputViewSaveModel } from '../../models/input';
import { InputComponent } from './input.component';

describe('InputComponent', () => {
  let component: InputComponent;
  let fixture: ComponentFixture<InputComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;
  let store: MockStore<fromInput.State>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [InputComponent],
      providers: [provideMockStore()],
      imports: [RouterTestingModule],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(InputComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ボタンアクションテスト', () => {
    it('確認ボタン押下時 Storeにデータを保存し、確認画面へ遷移する事', () => {
      // 処理のモックを作成
      const dispatchSpy = spyOn(store, 'dispatch');
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // 画面から入力された値を再現
      const formModel: InputViewSaveModel = {
        id: '111',
        name: 'koma-chan',
        address: 'tokyo',
      };

      // 実行されるアクションを定義(画面からの入力値をStoreへ保存するアクション)
      const expectedAction = InputActions.setRegisterPostData({ data: formModel });

      // テスト対象のメソッドを呼び出す
      component.onSubmit(formModel);

      // Storeのdispatchが、アクションを引数として呼ばれているか確認
      expect(dispatchSpy).toHaveBeenCalledWith(expectedAction);

      // navigateByUrlが、'/koma/confirm'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/confirm');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    store = TestBed.inject(MockStore);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、Store の dispatch に対するモックを作成
② 画面の入力値を Store に保存するアクションを定義
③ コンポーネント側の onSubmit を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数(アクション)で dispatch が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のStore関連のテストエラーは別途記事にしようと思います。

2022年5月12日木曜日

position: absolute;を使って重ねた画像にCSSで乗算の影をつける。


 

こんにちは、オフィス狛 デザイン部のSatoです。


先日、並んで表示されている複数の要素の上に、position: absolute;を使って画像を重ねて配置し、配置した画像に影をつけたいことがありました。

しかし、配置した画像は別の色の要素を跨いでいるため、普通の方法ではなかなか自然な影にならず困りました。

そこでCSSのブレンドモードで乗算の影をつけたら自然になるはずと思い試していたのですが、調整に時間がかかってしまいました。

あまり発生しないケースかもしれませんが、今後の自分用の備忘録も兼ねて解決方法を書いていこうかとおもいます。


上手くいかなかった方法


最初、imgをdivで囲みdrop-shadowを指定してあげれば影ができるはずですし、一緒に「mix-blend-mode: multiply;」を指定してみました。予想はできていましたが、影だけではなく画像まで乗算になってしまいました😔


それならば、imgタグに擬似要素「::before」をつけて、画像と同じサイズにした影を作りz-indexを使って画像下になるよう重ねてみようと思いました。……影が出てこないので、調べた所「::before」「::after」はimgには指定できないとのこと。初歩的なミスをやらかしてしまいました😔


それならば…imgを囲んでいるdivに擬似要素「::before」をつけようとしました。

影が画像の上に乗ってしまったのでz-indexを指定すると、乗算でなくなってしまいます😔mix-blend-modeとスタックコンテキスト

↑こちらのサイト曰くmix-blend-modeは同じスタックコンテキストに属している要素にしか効果がないらしいです。
ブレンドモードを使いこなすのは難しいですね…。



上手くいった方法


親要素の中に影用のdivを作り、画像と同じサイズにした影用の要素を画像の下に重なるよう配置した所、ようやく想像した通りの見た目になりました😄

See the Pen position: absolute;で浮かせた画像に乗算の影をつける by sato (@officekoma_sato) on CodePen.

上記のような感じになりました。

どちらの色のエリアでも違和感のない自然な影になり満足です✌️

あまり発生しないケースですし、影色を乗算にしなければ良いのですが、どうしても乗算を使いたいケースがあれば参考にしてみてください。

2022年4月28日木曜日

Angular ユニットテスト エラー( NullInjectorError: No provider for Router )の対応。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

突然ですが、Angularでユニットテストしてますか?
CLIでコンポーネントを作ると、「xxxxxx.component.spec.ts」が作成されますが、
「そのまま何も変えない(ユニットテスト使わない) or 邪魔だから消す」と、なっているかもしれないですね・・・・
まあ、Angularのユニットテスト、割と難しいので、気持ちは分かります。

という事で、Angularでユニットテストを書いていく上で、良く出るエラーの対処方法を記載していこうと思います。
今回はDI関連のエラーです。

NullInjectorError: R3InjectorError(DynamicTestModule)[Router -> Router]: 
  NullInjectorError: No provider for Router!
みたいなエラーが出てしまった場合の対応ですね。
メッセージそのままですが、Router絡みのエラーであることが分かります。

Angularでは、DI可能なクラスであれば、コンポーネントのコンストラクタに定義する事で、DIして使用可能になります。
[hoge.component.ts]
import { Router } from '@angular/router';
// (中略)
  constructor(private router: Router) {}
// (中略)
  onClickBack(): void {
  	// 「戻る」ボタンを押したら、「Top画面」に遷移する
    this.router.navigateByUrl('/hoge/top');
  }

・・・つまりテストクラス側でもDIをする必要があるって事ですね
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HogeComponent } from './hoge.component';

describe('HogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HogeComponent],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
って感じになります。
デフォルト(CLIによって作成された状態)から、
import { RouterTestingModule } from '@angular/router/testing';
と、
      imports: [
        RouterTestingModule,
      ],
を追加しています。

先ほどのエラー(NullInjectorError)を無くすだけであれば、実は上記でOKです。

ただ、当然テストケースは作らないといけないので、ついでにやってみましょう
[hoge.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
// Routerをimport
import { Router } from '@angular/router';
import { HogeComponent } from './hoge.component';

describe('HogeComponent', () => {
  let component: HogeComponent;
  let fixture: ComponentFixture<HogeComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [HogeComponent],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(HogeComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ボタンアクションテスト', () => {
    it('戻るボタン押下時  ホーム画面へ遷移する事', () => {
      // navigateByUrl処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // テスト対象のメソッドを呼び出す
      component.onClickBack();

      // navigateByUrlが、'/hoge/top'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/hoge/top');
    });
  });
});

ポイントとしては、DIしたクラスをテスト側で使用する為には、
    router = TestBed.inject(Router);
が必要、というところです。
実際のテストの部分は、プログラム内のコメントを参照して頂ければと思いますが、流れ的に、

① spyOn で、navigateByUrl のモックを作成
② コンポーネント側の onClickBack を呼び出す
③ expect の toHaveBeenCalledWith で、指定の引数で navigateByUrl が呼ばれたかを判定

となっています。

ちょっと長くなってしまったので、その他のDI関連のテストエラーは別途記事にしようと思います。

2022年4月22日金曜日

【C#】モーダル内に非同期でデータを表示させたかった話【ajax】

おはようございます。こんにちは。こんばんは。
オフィス狛 開発担当のnagoです。

今回は、最近初めて使用したC#(ASP.NET Core MVC)で、
なるほどなぁと思ったモーダル実装についてまとめたいと思います。
DBはPostgreSQL、ビューにはBootstrapを使用しました。

<やりたかったこと>
親画面からボタンクリック

親画面とは別のcshtmlファイルに記述したモーダルを開きつつ、
DBから持ってきたデータをモーダル内に表示させる

<できたもの>
コンサートなどのチケット購入サイト風サンプルを作成してみました。

・Topページ


↓詳細ボタンを押下したら

こんなかんじで表示。
・モーダル

データはこんなかんじ。

処理全体の流れとしては、
①親画面にモーダルのcshtmlを入れ込むためのdivを置いておく
②ボタンが押されたら、ajaxで非同期でコントローラにデータ取得用パラメータを渡す
③コントローラでデータ取得
④取得したデータを成形してビューモデルにセット
⑤コントローラからPertialViewでjsにcshtmlとビューモデルを返却する
⑥モーダル表示
というかんじで作ってみました。

<ソース>
・TopController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;

namespace DotNetTest.Controllers
{
    public class TopController : Controller
    {
        private readonly DotNetTestContext _context;

        public TopController(DotNetTestContext context){
            this._context = context;
        }

        [HttpGet]
        public IActionResult Top() {
            return View(this._context.performance);
        }
    }
}
・ModalController.cs
using Microsoft.AspNetCore.Mvc;
using DotNetTest.Models;
using System.Linq;

namespace DotNetTest.Controllers
{
    public class ModalController : Controller
    {
        private readonly DotNetTestContext _context;

        public ModalController(DotNetTestContext context){
            this._context = context;
        }

        [HttpPost]
        public IActionResult ShowModal(ModalViewModel viewModel) {
            try
            {
                if ( viewModel.id != string.Empty && _context.performance != null) {
                    if( Int32.TryParse( viewModel.id, out int intId )) {
                        // レコード取得
                        var performance = _context.performance.SingleOrDefault(p => p.id == intId);
                        if (performance != null) {
                            viewModel.name = performance.name;
                            viewModel.date = performance.date.ToString("yyyy年MM月dd日 HH:mm");
                            viewModel.place = performance.place;
                            viewModel.description = performance.description;
                            viewModel.errorMessage = string.Empty;
                            // ビューとビューモデルを返却
                            return PartialView("Modal", viewModel);
                        }
                    }
                }
                // エラーの場合はエラーメッセージ入りビューモデルを返却
                return Json(viewModel);
            }
            catch (System.Exception)
            {
                throw;
            }
        }
    }
}
・DotNetTestContext.cs
using Microsoft.EntityFrameworkCore;
using DotNetTest.Models;

namespace DotNetTest.Models
{
    public class DotNetTestContext: DbContext
    {
        public DotNetTestContext(DbContextOptions options) : base(options)
        {           
        }
        public DbSet performance { get; set; } = null!;
    }
}
・Performance.cs
namespace DotNetTest.Models
{
    public class performance
    {
        public int id { get; set; }
        public string name { get; set; } = string.Empty;
        public DateTime date { get; set; }
        public string place { get; set; } = string.Empty;
        public string description { get; set; } = string.Empty;
    }
}
・ModalViewModel.cs
using Microsoft.AspNetCore.Mvc;

namespace DotNetTest.Models
{
    [BindProperties]
    public class ModalViewModel
    {
        public string id { get; set; } = string.Empty;
        public string name { get; set; } = string.Empty;
        public string date { get; set; } = string.Empty;
        public string place { get; set; } = string.Empty;
        public string description { get; set; } = string.Empty;
        public string errorMessage { get; set; } = "エラーが発生しました";
    }
}
・Top.cshtml
@model IEnumerable<DotNetTest.Models.performance>
@{
  ViewData["Title"] = "Top";
}
<!doctype html>
<html lang="ja" >
<head>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
  <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" >
</head>
<body>
  <table>
    <tr>
      <td colspan="2">
        <p class="error_message" id="error_message"></p>
      </td>
    </tr>
    @foreach(var item in Model)
    {
      <tr>
        <td>
          <p>・ @item.name @item.date.ToString("yyyy年M月d日 HH:mm")〜</p>
        </td>
        <td class="button_td">
          <button type="button" class="btn btn-primary" data-toggle="modal" value="@item.id" onclick="openModal(this.value);">
            詳細
          </button>
        </td>
      </tr>
    }
  </table>
  <div id="modal_base"></div>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  <script src="~/js/site.js" asp-append-version="true"></script>
</body>
</html>
・Modal.cshtml
@using DotNetTest.Models
@model ModalViewModel
<!-- Modal -->
<div class="modal fade" id="modal" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">@Model.name</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <p>
          公演日時:@Model.date 〜<br>
          会場:@Model.place<br>
          説明:@Model.description
        </p>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-primary">
          予約
        </button>
      </div>
    </div>
  </div>
</div>
・site.js
function openModal(id) {
    $('#error_message').val('');
    // コントローラ名/メソッド名
    const url = new URL(window.location.href) + 'Modal/ShowModal';
    // パラメータ成形
    let form = new FormData();
    form.append('id', id);
    $.ajax({
        url: url,
        type: 'POST',
        processData: false,
        contentType: false,
        data: form
    }).done(function(data){
        const data_stringify = JSON.stringify(data);
        const data_json = JSON.parse(data_stringify);
        if(data_json.errorMessage != null) {
            $('#error_message').append(data_json.errorMessage);
        } else {
            // 親画面に置いておいたdivに、コントローラから受け取ったモーダルのcshtmlをセットする
            $('#modal_base').html(data);
            // モーダル表示
            $('#modal_base').find('.modal').modal('show');
        }
    });
}

親画面と子画面(モーダル)のcshtmlを別ファイルで管理したかったのと、
jsファイルも別ファイルで管理したかったということがあり、苦戦したのですが、
これで思い通りの動きに出来た時はすごく嬉しかったです。
もし同じようなことで躓いた方がいらしたら、参考になれば幸いです。

参考URL:
https://docs.microsoft.com/ja-jp/dotnet/api/system.web.mvc.controller.partialview?view=aspnet-mvc-5.2
, , ,

2022年3月31日木曜日

要素同士の余白に使える!gapプロパティが便利

こんにちは、オフィス狛 デザイン部のSatoです。


 Internet Explorerシリーズのサポートが今年6月15日終わりますね。

便利なCSSプロパティが「モダンブラウザでは問題ないけれどIEで非対応だった!」と焦るシチュエーションがなくなり、モダンブラウザで使えるさまざまなCSS要素がIEの存在を気にせず使えるようになると思うと、個人的にはかなりありがたいです!


IEを気にしなくて良くなり、気軽に使えるようになったCSS要素で「要素をタイル状に横並びにする」レイアウトをCSSで実装する際に使える「gapプロパティ」を使ってみた所、とても便利でしたので今回のブログでご紹介します。



gapプロパティは要素と要素の間を指定するプロパティです。

marginに近いですが、要素の有無に関係なく余白を指定できるmarginと違い、gapプロパティは要素と要素の隙間を指定できます。


今まで「840pxの横幅の要素の中に、要素を3つずつ横並びで表示する、要素と要素の間は4px」といったレイアウトを作成する際余白を開けるために、要素の数が決まっている際はjustify-content: space-between;で並べたり…、要素の数に増減がある際はjustify-content: space-between;を使わずmarginで2番目の要素だけに余白をつけたり…。

などと私の場合考えることが多かったです。

レスポンシブサイトでのスマホビュー時のレイアウト変更にも少し手間がかかっていました。


しかし、gapプロパティを使うと余白をデザインの際に横に並べる数が変わった時などに変更する数値や箇所がわかりやすい+要素が右寄せで表示+レスポンシブの際のコード変更箇所が減る…などなど良いこと尽くめです。

しかもgapプロパティは上下右左個別に指定可能なんです。とってもありがたいです。


See the Pen 要素を並べて表示 by sato (@officekoma_sato) on CodePen.

👆デモを作成してみました。

HTML&CSSも見れますので、参考にしてみてください。

今後はグリッドレイアウトなどと一緒に使うことで、色々な可能性が広がりそうですね。



今回ご紹介したgapプロパティはリニューアルしたオフィス狛のWebサイトのこまちゃん紹介ページでも使用しています。

よろしければ、リニューアルしたオフィス狛のWebサイトも見ていってください。

2022年2月24日木曜日

【JavaScript】メールテンプレート内のプレースホルダーを独自に式展開(置換)する方法。

オフィス狛 技術部のyuckieee(ゆっきー)です。

■はじめに

今回は、私が関わったプロジェクトで「ユーザがメールテンプレートを自由に編集できるようにしたい!」という要望を受けて、その実現方法を検討・実装した際のお話です。
要件実現にあたって使用したのがJavaScriptの「replace」という関数なのですが、この関数が非常に使える子で...ふるえました(,,゚Д゚)。 ←初心者感丸出しなのと語彙力(笑)

今後も活用する場面がありそうなので、自分の理解向上と備忘録としてコチラにまとめます。

■今回の要件整理

最初に話したとおり、今回の要望はメールテンプレートをユーザが自由に編集・管理したいというものです。また、この要件には「メールの宛先によって一部の内容(名前とか、契約内容とか)はシステムで可変となるようにしたい」という要望も含まれています。
なので、ざっくりこんな感じの要件です。

[要件箇条書き]
     ①メールテンプレートは外部に保存可能とし、ユーザが自由に編集できるようにして欲しい
       ②ユーザが可変指定した箇所は、システムで自動的に置き換えて欲しい

      可変にしたいと言うのは、メールテンプレート内にプレースホルダーを埋め込みたいというイメージです。 そのため、イメージとしては、以下のようにユーザがメールテンプレートを定義したらプレースホルダー(${})部分は、該当する情報に置き換えられて欲しいという感じです。
      (まんま、JavaScriptのテンプレートリテラル内で式展開させる感じと同じです。)

      ■メールテンプレート
      ${last_name} {first_name} 様
      この度は、${company_name}の${plan_name}にご登録いただき、ありがとうございました。
       ↓
      ■メール送信内容
      オフ狛 ゆっきー 様
      この度は、株式会社オフ狛のスペシャルプランにご登録いただき、ありがとうございました。
      

      ■検討結果と実装内容

      テンプレートリテラレルを使えば解決じゃん?と思うのですが、外部から読み込んだ文字列にプレースホルダーが書いてあったとしても式展開されず、単なる文字列になっちゃうんですよね....(´・ω・)
      まぁ、そうですよね、、、ということで、結果、独自にプレースホルダー(${})を置換する関数を作ることにしました。それがコチラ↓

      /**
       * テンプレート置換処理
       * @param  {string} template // 外部から読み込んだメールテンプレート文字列
       * @param  {object} jsonObj  // メールテンプレート内のプレースホルダー置換対象情報
       */
       const templateRepalcer = (template, jsonObj) 
                               => template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
      

      以上です!

      ■実装関数の使用例

      つづいて、実装した関数を使用して簡単な使い方例を以下に紹介します。

      // メールテンプレートのテンプレートリテラレルを顧客情報に置き換える関数を定義
       const templateRepalcer = (template, jsonObj) 
                               => template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
      
      // メールテンプレート(!!実際は外部(DBなど)から読み込む!!)
      const mailTempalte = "${last_name} ${first_name} 様\nこの度は、株式会社オフ狛の${plan_name}にご登録いただきありがとうございました。"
      
      // 顧客情報(!!実際は外部(DBなど)から読み込む!!)
      const userInfo = {
          "user_id": "ID00001",
          "last_name": "オフ狛",
          "first_name": "ゆっきー",
          "plan_name": "スペシャルプラン",
          "mail_address": "yuckieee@officekoma.co.jp"
      }
      
      // メールテンプレートのプレースホルダーを顧客情報に入れ替え
      const mailContents = templateRepalcer(mailTempalte, userInfo);
      
      // 出来上がった変数を本文に編集すれば出来上がり!(この例ではコンソール出力のみ)
      console.log(mailContents);
      

      上記実行の結果、コンソールに出力される内容は以下のとおりです。

      オフ狛 ゆっきー 様
      この度は、株式会社オフ狛のスペシャルプランにご登録いただきありがとうございました。
      

      以降、実装関数について詳細を解説していこうと思います。

      ■関数の詳細説明

      この関数を説明する前にreplace関数についての知識が必要ですが、こちらの詳細は公式(?)ページを参照された方が分かりやすいですし、ここでの説明を割愛します。

      [replace関数の説明サイト]
      MDN Web Docs:String.prototype.replace()
      JavaScript Primer:文字列の置換/削除

      作成した関数は2つあり、replace関数を呼ぶ大元のtemplateReplacerという関数と、replace関数の第二引数で使用する無名関数です。 templateReplacer自体は、テンプレートと差し替えデータを引数に、replace関数を呼び出しているだけですので説明は省略します。
      以下より、replace関数の第2引数で指定している無名関数に焦点をあてて説明したいと思います。

      まず、replace関数全体を眺めてると、以下のとおり、第2引数は、第1引数の結果を引き継いで処理を行う作りとなっていることが分かります。

      template.replace(/\${([^}]+)}/g, (_, prop) => jsonObj[prop]);
      [置換の母体となる文字列].replace(第1引数:[置換対象を正規表現で指定], 第2引数:[第一引数の結果を元に置換処理]);

      無名関数を説明する上で、第1引数も重要となってくるため、第1引数と第2引数の両方を順に説明します。

      第1引数: 置換対象の文字列又は、正規表現を指定

      replace関数の第1引数には、置換対象にしたい文字列を固定で指定するか、正規表現を使って指定する事ができます。
      今回は、汎用性を高めるために正規表現を使用しています。
      なお、/\${([^}]+)}/の箇所は、書き方は色々あると思うので、正規表現をググってみてください。
      .?*とかでも行けると思います。
      ()表記については、第2引数内で説明します。正規表現による抽出とは直接関わりません。

      また、/gを指定することで置換の母体となる文字列全体での抽出が可能になります。
      この指定を行わない場合は、対象が複数あった場合でも、最初に正規表現にひっかかった1件だけが置換される結果となるので注意が必要です。

      第2引数: 置換する文字列又は、置換処理を指定

      第2引数には、置換後の文字列を固定で指定するか、処理自体を指定することが出来ます。今回紹介した関数では、第2引数に無名関数を定義して、置換処理を行う形をとっています。
      なお、第1引数での結果をもとに、以下の引数を無名関数に引き渡すことが可能です。
      ※ブラウザによっては、5つ引数があるようですが、ここでは説明対象外とします。

      (match, p1, p2..., offset, string )

      それぞれの具体的な値について説明します。
      ※実装例で上げたメールテンプレート中の${last_name}を例に上げて説明します
      引数 説明
      match 第1引数の正規表現にマッチした文字列が設定されます。
      今回の例で言うところの${last_name}が該当箇所です。なお、今回使用しないため、関数では_と表記しています。
      p1,p2... 第1引数でキャプチャした文字列が引数として渡されます。
      キャプチャは、第1引数の正規表現内でキャプチャしたい箇所を()で囲むことで指定が可能です。
      複数指定した場合は、指定した分引数が増えていきます。今回は1対1で置換したかったので1つだけ指定しています。
      今回の例ではlast_nameという箇所が抜き出されることになります。
      offset 第1引数の正規表現でヒットした文字列の最初の位置を返します。${last_name}であれば、開始位置の0がセットされる感じです。今回は使用していません。
      string 検索対象全体がセットされます。今回でいうと"${last_name} ${first_name} 様\nこの度は、株式会社狛の"${plan_name}にご登録いただきありがとうございました。"です。今回は使用していません。

      上記の引数を元に置換対象を指定していますが、やっていることはJSONのキーを指定して、キーに紐づく値を取得しているだけです。

      (_, prop) => jsonObj[prop]

      今回の例では、以下のようなJSONが与えられていますので、第一引数でキャプチャしたキー(last_name)を元に、JSONのキーに紐づく値(オフ狛)を取得し、それを置換対象としています。

      {
          "user_id": "ID00001",
          "last_name": "オフ狛",
          "first_name": "ゆっきー",
          "plan_name": "狛スタンダードプラン",
          "mail_address": "yuckieee@officekoma.co.jp"
      }
      

      ■まとめ

      私は、今までreplace関数を単純に置換してくれるだけの関数ででしょ?って思っていました。
      ですが、こんなに汎用性の高い関数であるということを知って、目からウロコ...本当にreplace関数さん申し訳ありません...!!!ってなりました^^;
      特に引数として関数を指定するというスタイルは、自分の作る関数にも活かして行けると面白いなと感じます。

カラム内の区切り文字でデータを分割して取得する方法(SQLServer)。

オフィス狛 技術部のHammarです。

ある開発で、テーブルのカラムにカンマ区切りで値が入っているデータがあるのですが、ここからカンマ区切りで1つのレコードとして分割してデータ抽出したいと要望がありました。
取得イメージ的には、例えば以下のようなfruits_boxテーブルがあったとします。

+----+-------+-----------------------+
| id | name  | fruits                |
+----+-------+-----------------------+
|  1 | boxA  | apple,grape           |
|  2 | boxB  | apple,orange,banana   |
|  3 | boxC  | banana                |
+----+-------+-----------------------+
こちらから

+----+-------+-----------------------+
| id | name  | fruits                |
+----+-------+-----------------------+
|  1 | boxA  | apple                 |
|  1 | boxA  | grape                 |
|  2 | boxB  | apple                 |
|  2 | boxB  | orange                |
|  2 | boxB  | banana                |
|  3 | boxC  | banana                |
+----+-------+-----------------------+
のように取得する感じです。

カラム内にカンマ区切りでデータを入れることは結構あると思います。ただ、そのデータをレコードとして分割して出力することがなかったのですが、今回調べてみて意外と簡単な方法があったのでちょっと書いてみたいと思います。 RDBMSによってそれぞれ書き方は異なるようですが、開発でSQLServerを使っていたので、今回はそのSQLServerで上記のような構成でデータを取得する方法となります。

■STRING_SPLIT関数

まずSQLserverで、区切り文字で分割して結果を取得する方法としては、STRING_SPLIT関数を使う方法があります。
STRING_SPLIT関数は、
STRING_SPLIT(文字列, 区切り文字)
の方法で利用することができます。
使い方としてはこんな感じです。

> SELECT * FROM STRING_SPLIT('apple,orange,banana', ',');
+-----------+
| value     |
+-----------+
| apple     |
| orange    |
| banana    |
+-----------+

これはこれで使えそうなんですが、そもそもこれを使うには1レコード対象にしか使えません。
なので、今回のような複数レコードが存在する場合にはちょっと無理そうです。

■CROSS APPLY句

ということで、もう1つの方法として、「CROSS APPLY」という結合演算子と合体技で取得する方法です。
APPLY句については今回細かく記載は省きますが、テーブルから取得したデータをテーブル値関数の結果として組み合わせて使いたい場合にこのAPPLYという演算子は有効です。

これを使ってSQLを書くと以下のように書くことが出来ます。
> SELECT * 
> FROM fruits_box AS FB
> CROSS APPLY STRING_SPLIT(fruits, ','); 
+----+-------+-----------------------+
| id | name  | fruits                |
+----+-------+-----------------------+
|  1 | boxA  | apple                 |
|  1 | boxA  | grape                 |
|  2 | boxB  | apple                 |
|  2 | boxB  | orange                |
|  2 | boxB  | banana                |
|  3 | boxC  | banana                |
+----+-------+-----------------------+
という感じで、ほしいデータの結果を得ることが出来ました。
結構簡単な記述でいけますね!

1点注意点としては、上記テーブルのfruitsにNULLがあった場合、その結果は返ってこないので、もしNULLも結果として返したい場合は、「OUTER APPLY」を使うことでNULLレコードも結果として得ることができるようになります。

もし上記のような、カラム内の区切り文字で、それぞれレコードとしてデータ取得が必要な場合にはご参考にしてみてください。

SQL Serverで複数カラム(NULL許可のカラムを含む)のユニーク制約を設定する方法。


オフィス狛 技術部のJoeです。

データベースで複数カラムを組み合わせたユニーク制約の設定が可能ですが、データベースによって、NULL値を重複として扱うかどうかが違っています。

例えばMySQLは、NULL値を重複として扱いませんが、SQL Serverは、NULL値を重複した値として扱います。

今回は、SQL ServerでNULL値を含む複数カラムの組み合わせで、ユニーク制約を設定する方法をご紹介したいと思います。
※下記の内容はSQL Server 2017で確認しています

まず、下記のようなテーブルを作成、データを追加します。

【テーブル作成】
CREATE TABLE fish
(
  id bigint IDENTITY(1,1) NOT NULL,
  name nvarchar(10) NOT NULL,
  code varchar(2) NULL,
  PRIMARY KEY CLUSTERED(id)
);

+--------+--------+------+
| id(PK) | name   | code |
+--------+--------+------+
|      1 | かつお |   11 |
|      2 | かつお | NULL |
|      3 | かつお | NULL |
|      4 | まぐろ |   11 |
|      5 | まぐろ |   22 |
+--------+--------+------+

このテーブルに「name」と「code」の組み合わせの複数のカラムに対して、ユニーク制約の設定しようとすると、エラーが発生します。

【ユニーク制約の設定SQL】
ALTER TABLE fish ADD CONSTRAINT uq_fish UNIQUE ( name, code );

【エラー内容】
The CREATE UNIQUE INDEX statement terminated because a duplicate key was found for the object name 'dbo.fish' and the index name 'uq_fish'. The duplicate key value is (かつお, ).

エラー内容のとおり、「かつお, NULL」の組み合わせで、既に重複しているデータがあるので、ユニーク制約を設定することが出来ませんでした。
(SQL Serverは、NULL値を重複した値として扱うため)

もし、NULL値を重複した値として扱いたくない場合、下記の方法で設定が可能です。

【設定方法1】スカラー関数とチェック制約

スカラー関数とチェック制約を組み合わせて、NULL値を含む複数カラムでのユニーク制約を設定可能です。
-- スカラー関数作成
CREATE FUNCTION fishCheck (
  @id bigint,
  @name nvarchar(10),
  @code varchar(20)
)  
RETURNS BIT
WITH RETURNS NULL ON NULL INPUT -- ※1
AS
BEGIN
  RETURN
    CASE WHEN EXISTS
      (SELECT 1
      FROM fish
      WHERE id <> @id -- ※2
      AND name = @name
      AND code = @code)
    THEN 1 ELSE 0 END
END;

-- チェック制約の設定
ALTER TABLE fish ADD CONSTRAINT ck_fish
  CHECK (dbo.fishCheck(id, name, code) <> 1); -- ※3
※1 引数のいずれかが NULL の場合に関数の本体を呼び出すことなく NULL を返します
そのため、code(NULL許可のカラム)がNULLであれば、重複のチェックは行いません
※2 重複レコードを探すSQLでは、チェックする(挿入、更新しようとする)データは除きます
※3 スカラー関数の結果が「1(重複あり)」でなければ、チェックOKとします

と、上記のように設定することができますが、2つの定義が必要ですし、少し解り難いかなという印象です。
もう一つの下記方法ですと、1つの定義で設定が可能です。

【設定方法2】ユニークインデックス

ユニークインデックスでは、WHERE句で条件を指定できます。
「code(NULL許可のカラム)がNULL以外」の条件を指定することで、NULLを除外したユニーク制約の設定が可能です。
CREATE UNIQUE INDEX uq_idx_fish ON fish(name, code) WHERE code IS NOT NULL;



もしSQL Serverで、NULL許可のカラムにユニーク制約を設定する場合、上記を試してみてください。

ただ、MSDNに「一意制約の列を選択する場合は、NOT NULL と定義された列を選択します。」との記載があるので、
ユニーク制約に設定するカラムには、NULL値が入らない前提での設計が必要ですね。
一意インデックスの作成

2022年2月22日火曜日

【Angular】Reactive formを使用して、要素が動的なフォームを作成する方法。


オフィス狛 技術部のmmm(むー)です。

先日Angularにて、Reactive Formを使用し、要素が動的に増えるフォームを作成したかったのですが、公式のページを読んでもなかなかイメージがわかず、苦労したので備忘録として残します。

今回、記事のためにAngularのバージョンは13.2.0を使用して確認しました。
業務で使用した際、Angular8系で動くことも確認しています。

対応方法

作成したかったフォームの構造は下記のようなイメージです。「ID、名前、メモ」で1セットのデータが動的に増減します。
早速ですが、コンポーネントのコードを記載します。
 // app.component.ts
import { Component, VERSION } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormArray } from '@angular/forms';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  constructor(private formBuilder: FormBuilder) {}

  // フォーム(枠) = formタグに該当します
  formGroup: FormGroup = this.formBuilder.group({
    features: this.formBuilder.array([]),
  });

  // 実際はAPIからの戻り値を使用すると思いますが、今回は確認用に以下の値を定義します
  list = [
    { id: 1, name: 'Haru', memo: '春' },
    { id: 2, name: 'Natsu', memo: '夏' },
  ];

  ngOnInit() {
    // 空のフォーム(中身) = formタグの中に入れる項目をまとめたものになります
    // 今回の例ですと、「ID、名前、メモ」で1つの単位となります。
    let testForm = this.formBuilder.group({});

    // 初期値とバリデーションを設定します。
    this.list.forEach((obj) => {
      testForm = this.formBuilder.group({
        name: [obj.name, Validators.required],
        memo: [obj.memo],
      });

      // フォーム(中身)をフォーム(枠)に追加します
      this.features.push(testForm);
    });
  }

  // featuresはAbstractControlを返すので、型をFormArrayにします
  get features(): FormArray {
    return this.formGroup.get('features') as FormArray;
  }
}

FormArrayを使用し、各FormControlの値をまとめることができます。
  ・FormControlは1つの入力項目(例えば、inputタグ単位)となります。
・更に、FormGroupを使用し、各FormArrayの値をまとめることができます。
  ・FormArrayが不要な場合は、FormGroupとFormControlだけで問題ありません。

HTMLは下記となります。(バリデーション等は記載していません)
 // app.component.html
<form [formGroup]="formGroup">
  <ng-container formArrayName="nestForm">
  <!-- グループ名(formGroupName)はインデックスになります -->
    <ng-container *ngFor="let val of nestForm.controls; let i = index">
      <ng-container [formGroupName]="i">
        <p>ID:{{ list[i].id }}</p>
        <p>
          名前:<input
            type="text"
            class="form-control"
            formControlName="name"
          />
        </p>
        <p>
          メモ:<input
            type="text"
            class="form-control"
            formControlName="memo"
          />
        </p>
        <hr />
      </ng-container>
    </ng-container>
  </ng-container>
  <button type="submit" [disabled]="formGroup.invalid ">更新</button>
</form>


このコードにバリデーションをチェックし、エラーメッセージを表示する処理を追加したい場合、以下のように作成することができます。
FormControlの名前を固定できるので(下記の例では、「name」)、バリデーションチェック用の処理を重複して書く必要がありません。
  // app.component.ts
// 名前が入力されているかチェックする
errorNameRequired(i: number): boolean {
    return (this.formArray.get('features')).controls[i].get('name').hasError('required');
  }

 // app.component.html
  <p>
    名前:<input
      type="text"
      class="form-control"
      formControlName="name"
    />
	 <!-- エラーメッセージを表示するためのコードを追加↓ -->
	 <ng-container *ngIf="errorNameRequired(i)">名前を入力してください。</ng-container>
  </p>


ちなみにフォームの値に直接アクセスしたい場合は、以下のように確認することができます。
console.log(this.nestForm.value[0].name) // Haru

以上となります。参考になりましたら幸いです。

参考:
https://angular.io/api/forms/FormGroup (公式)
https://angular.io/api/forms/FormArray (公式)

【Laravel】ミドルウェアでクライアントからのリクエスト内容を書き換える。


オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

今回もLaravelネタになります。
前回、「Jetstream + Fortify」を画面無し(APIとして)で運用する為の方法を記載しました。
その中で、
if ($request->expectsJson()) {
    // クライアントからJSONレスポンスを要求されている場合、リダイレクトさせず、JSON形式メッセージをレスポンスする。
    return response()->json(['message' => 'Already authenticated.'], 200);
}
のような、記載をしたかと思います。
これは、とりあえず、「API化したものの、画面としても使える手段を残す為」となります。

前回は、特に説明しなかったのですが、$request->expectsJson()で、「APIとして呼ばれている」事を判断していました。
これ、正確にはクライアントに依存しているのですが、要は、httpヘッダに「Accept:application/json」を設定している、と言うことになります。
ただ、必ずしもクライアント側が設定してくるとは限らない・・・

そのような場合、クライアントからのリクエストを書き換えて、ヘッダに常に「Accept:application/json」を設定している、と言う状態にすることが出来ます。

まずは、ミドルウェアにクラスを追加します。
[app/Http/Middleware/RequireJson.php](新規追加)
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class RequireJson
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        // クライアントからのAccept要求(クライアントがResponseで欲しいデータ形式)は全てJSONとする。
        $request->headers->set('Accept','application/json');
    }
}
これでヘッダの書き換え処理は出来ました。
あとは、この処理が、クライアントからのリクエストの度に必ず呼ばれるようにしないといけません。

そこで、「Kernel.php」に上記のクラスを読み込む処理を追加します。
[app/Http/Kernel.php]
    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
        \App\Http\Middleware\RequireJson::class, // ←追加
    ];
これで完了となります。
※ただ、この設定をした時点で、完全に画面としては使用出来なくなるので、注意が必要です。

ミドルウェアは、色々使い道があるので、もっと使っていきたいですね。

2022年1月31日月曜日

【Angular】条件によって、バリデーションを設定したり、無効にする方法。


オフィス狛 技術部のmmm(むー)です。

以前、Angularのプロジェクトでフォームの画面を作成しました。
そのフォームのinput項目に当然バリデーションを設定していたのですが、条件によってバリデーションを設定したり、無効にする必要がありました。
今回はその時のコードを備忘録も兼ねて紹介いたします。

対応方法

画面は下記のようなフォームが存在するとします。
名前:___________  メモ:___________ 

コードは下記となります。
 // バリデーション
  validations = {
    'name': [Validators.required],
    'memo': [Validators.maxLength(20)],
  };

ngOnInit() {
    // 初期値(空文字 '')とバリデーションを設定
    this.exampleFormGroup = this.formBuilder.group({
        name: ['', this.validations.name],
        memo: ['', this.validations.memo]
    });
}

// バリデーションを削除します
public deleteValidation(form: FormGroup): void {
    for (const formControlName in form.controls) {
        form.get(formControlName).clearValidators();
        form.get(formControlName).updateValueAndValidity();
    }
}

// バリデーションを設定します
public setValidations(form: FormGroup): void {
	for (const formControlName in form.controls) {
	    form.get(formControlName).setValidators(this.validations[formControlName]);
	    form.get(formControlName).updateValueAndValidity();
	}
}

// バリデーションを切り替えます
switchFormValidations(): void {
	if (condition) {
		this.deleteValidation(this.exampleFormGroup);
	} else {
		this.setValidations(this.exampleFormGroup);
	}
}

clearValidators()
・バリデーションを無効にします。clearValidators()を使用した後は、バリデーションの変更を更新するためにupdateValueAndValidity()を合わせて使用してください。
setValidators()
・バリデーションを設定します。setValidators()を使用した後は、バリデーションの変更を更新するためにupdateValueAndValidity()を合わせて使用してください。
・ちなみに公式のページに記載されていましたが、既存のバリデーションに影響なくバリデーションを追加する場合はaddValidators()を使用するように推奨されているようです。

以上となります、参考になりましたら幸いです。

参考:https://angular.io/api/forms/AbstractControl (公式)

2022年1月21日金曜日

2022年オフィス狛の年賀状の制作秘話。


新年あけましておめでとうございます!オフィス狛 デザイン部のSatoです。
2022年度もどうぞよろしくお願いいたします!


昨年2021年ですが、個人的には普段の数倍くらいの速さで過ぎていったような感覚があります。
……あれ?もしかしてこの感覚って、ただの老化ですかね?
ともかく、一日も早く新型コロナウイルスに恐れなることなく、安心して過ごせるようになることを願うばかりです。


1月も終盤ですが、デザイン部の年明け初めての記事ですので去年同様、今年の年賀状のおはなしをしようと思います。
↓去年の年賀状の記事はコチラ。

2022年の年賀状はこちら!


前年同様、弊社サイトやSNSなどのアイコンなどで登場しているこまちゃんとここまちゃんが今年の干支のトラと一緒に、インターネットミームに詳しい方ならきっと見覚えがある「あの」ポーズをしているイラストにしてみました。
そう、虎と同じネコ科の猫がヘルメットを被って指差し呼称をしているあのイラストです。


元々の猫のイラストを描いていたくまみね先生のブログ曰く、ポーズ自体は一般的なポーズである為使うことを禁止していないようなので、指差し呼称をしている有名なポーズを真似してみました。




今年の年賀状のラフです。
②のラフはトラ…鬼のパンツ…鬼…今話題の鬼●の刃のパロディはどうだろうか!と思ったので描いてみたのですが、ラフの時点で私の絵が下手すぎたせいか、鬼●の刃のキャラクターのパロディだとわかりにくい…と言われてしまいました。

しかし、落書きで描いた指差し呼称をしているトラがいい感じだったので、こまちゃんたちを追加して③のラフを作成し、相談したところ良い!と言ってもらえたので、このイラストを清書しました。



今回の年賀状の端にいる白い犬のキャラクター「ここまちゃん」は、ご存知ない方もいらっしゃると思いますが、Office狛10周年記念の弊社WebサイトのTopページのイラストでデビューしたキャラクターです。

狛犬とシーサーがモデルのこまちゃんと同じように、ここまちゃんは日本の縁起の良い犬モチーフとして有名な犬張子がモデルです。(猫ではなく、犬です!)
こまちゃんの弟分という設定です。

今回のブログ記事のサムネイルであるここまちゃんが電話しているイラストは、今年の年賀状に使おうと思い描いたのですが、没になったイラストです。

個人的には電話ここまちゃんは本家の猫に負けず劣らず結構可愛いと思ったのですが、短い手足でヨシ!しているここまちゃんの方が可愛くなってよかったです。


今後はここまちゃんも描くことになるのですが、ここまちゃんの丸顔のバランスが難しいので、ささっとかけるようになるよう、練習を頑張ります!

2022年1月11日火曜日

【Laravel】Jetstream + Fortify を画面無し(APIとして)で運用する。


オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

Webシステムにおける認証機能、重要ですが、作成するの大変ですよね・・・・。
その悩みを解決するのがLaravelでは、「Jetstream + Fortify」な訳ですが、今回、「フロントとバックエンドを完全に分離したいので、認証機能はAPIで呼び出す方式にして欲しい」との要望があり、Jetstream + Fortifyを画面無し(APIとして)で実装することになったので、記録に残しておこうと思います。

(1)各バージョンと前提

まずは、バージョンですが、
・Laravel 8.27.0
・PHP 8.0.9
となります。

そして、前提条件ですが、
Laravel Jetstreamの設定が完了していて、画面を経由してのログインなどは出来ている
とします。

(2)Fortify設定値変更

早速対応方法としては・・・・Fortifyの設定値の変更となり、
[config/fortify.php]
    // Viewを使用しない場合、trueからfalseに変更
    'views' => false,
で、完了、以上。
・・・・としたいところなのですが、そうも行きません。

(3)パスワードリセットのルート定義追加

まずは、本家サイトにも記載がありますが、パスワードリセットのルートは定義する必要があります。
[routes/web.php]
/*
| FortifyのViewを使用しない場合でも、「password.reset」のルートは定義する必要がある。
| https://laravel.com/docs/8.x/fortify#disabling-views-and-password-reset
*/
Route::get('/reset-password/{token}', ResetPasswordController::class)
    ->name('password.reset');

ResetPasswordControllerは新規で作成します
[app/Http/Controllers/ResetPasswordController.php](新規作成)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;

class ResetPasswordController extends Controller
{
    public function __invoke(Request $request)
    {
        // メールアドレスとトークンを返却する
        $result = [
            'email' => $request->email,
            'token' => $request->token
        ];
        return response()->json($result);
    }
}

さて、ここで疑問なのですが、なぜ、ルート定義する必要があるのでしょうか?
それを説明する為に、LaravelとFortifyの中を覗いてみましょう
[vendor/laravel/fortify/routes/routes.php](抜粋)
    if (Features::enabled(Features::resetPasswords())) {
        if ($enableViews) {
            Route::get('/forgot-password', [PasswordResetLinkController::class, 'create'])
                ->middleware(['guest'])
                ->name('password.request');

            Route::get('/reset-password/{token}', [NewPasswordController::class, 'create'])
                ->middleware(['guest'])
                ->name('password.reset');
        }

        Route::post('/forgot-password', [PasswordResetLinkController::class, 'store'])
            ->middleware(['guest'])
            ->name('password.email');

        Route::post('/reset-password', [NewPasswordController::class, 'store'])
            ->middleware(['guest'])
            ->name('password.update');
    }
抜粋して記載していますが、問題の「password.reset」、ありますね。
ただ、よく見てみると、「if ($enableViews) {」となっているので、Viewが無効になっていると定義されない、と言うことが分かります。

なるほど、だから、自分達で定義する必要があると。では、なぜ定義が必要なのか、さらに見てみると、
[vendor/laravel/framework/src/Illuminate/Auth/Notifications/ResetPassword.php](抜粋)
    public function toMail($notifiable)
    {
        if (static::$toMailCallback) {
            return call_user_func(static::$toMailCallback, $notifiable, $this->token);
        }

        if (static::$createUrlCallback) {
            $url = call_user_func(static::$createUrlCallback, $notifiable, $this->token);
        } else {
            $url = url(route('password.reset', [
                'token' => $this->token,
                'email' => $notifiable->getEmailForPasswordReset(),
            ], false));
        }

        return $this->buildMailMessage($url);
    }
これは、パスワードリセットをした時にメールを送る処理ですね。
ここで「password.reset」のルート定義を使ってURLを作っている訳です。
なので、定義は必要になる、ということですね。

これで、今度こそ画面無し(API化)完了・・・・とならないんですね、これが。

(4)未認証・認証済みの処理追加

ちょっと微妙な調整が必要になります。
そもそもの動きとして、画面有りのJetstream + Fortifyでは、
・ログイン状態で、ログイン画面に遷移しようとすると、ホーム画面へリダイレクトする
・未ログイン状態で、ログインが必要な画面に遷移すると、ログイン画面へリダイレクトする
という動きになります。

実はこの動き、
[config/fortify.php]
    // Viewを使用しない場合、trueからfalseに変更
    'views' => false,
の設定をしても変わりません。

API化した状態で考えると、
・バックエンド側でログイン状態(認証済み)の時に、フロントからログインAPIなど未ログイン状態が前提のAPIを呼ばれると、ホーム画面へのリダイレクト指示をレスポンスする
・バックエンド側で未ログイン状態(未認証)の時に、ログイン済み必須なAPIを呼ばれると、ログイン画面へのリダイレクト指示をレスポンスする
となってしまいます。

これは何とかしたいですね。

という事で、まずは前者の
認証済みの時に、ログインAPIなど未ログイン状態が前提のAPIを呼んだ場合、です。

この場合、MiddlewareのRedirectIfAuthenticated.phpに処理が書かれています。
[app/Http/Middleware/RedirectIfAuthenticated.php]
    public function handle(Request $request, Closure $next, ...$guards)
    {
        $guards = empty($guards) ? [null] : $guards;

        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                return redirect(RouteServiceProvider::HOME);
            }
        }

        return $next($request);
    }
なるほど、確かにホーム画面にリダイレクトしていますね。
では、早速修正して・・・・・
[app/Http/Middleware/RedirectIfAuthenticated.php](修正後)
    public function handle(Request $request, Closure $next, ...$guards)
    {
        $guards = empty($guards) ? [null] : $guards;
        foreach ($guards as $guard) {
            if (Auth::guard($guard)->check()) {
                if ($request->expectsJson()) {
                    // クライアントからJSONレスポンスを要求されている場合、リダイレクトさせず、JSON形式メッセージをレスポンスする。
                    return response()->json(['message' => 'Already authenticated.'], 200);
                }
                return redirect(RouteServiceProvider::HOME);
            }
        }

        return $next($request);
    }
と、こんな感じで、APIとして呼ばれた場合、「Already authenticated.」をレスポンスするようにしました。
後は、フロント側で良きに計らってもらうようにしましょう。

次は、
未認証の時に、ログイン済み必須なAPIを呼ばれた場合、です。
この場合、MiddlewareのAuthenticate.phpに処理が書かれています。
[app/Http/Middleware/Authenticate.php]
    protected function redirectTo($request)
    {
        if (! $request->expectsJson()) {
            return route('login');
        }
    }
あ、でも、これを見る限り、APIとして呼ばれた場合は、ログイン画面のリダイレクトはレスポンスされない気がしますね。
何もレスポンスされないのも困ってしまうので、こちらは、
[app/Http/Middleware/Authenticate.php](修正後)
    protected function redirectTo($request)
    {
        return $request->expectsJson()
            ? response()->json(['message' => 'Unauthenticated.'], 401)
            : route('login');
    }
このように、401エラーとして、Unauthenticated.を返却するようにしました。
こちらも、後は、フロント側で良きに計らってもらうようにしましょう。

ということで、API化完了となります。

細かい部分はまだまだやる事(Viewを消したり、等々)があるのですが、今回はこのぐらいで十分かと思います。

LaravelのJetstream、Fortifyはかなり便利なので、どんどん使っていきたいですね。


【AWS】API GatewayからS3に配置したファイルを直接レスポンスする。

オフィス狛 技術部のyuckieeeです。
今回もまた「API Gateway」を使用したAPI用Mockに関する小ネタをご紹介しようと思います。

本投稿の背景

API Gatewayで快適Mockライフを送る予定だったのですが、思わぬ壁にぶつかりました。
何かというと、データ量の多いJSONデータ(2~6MB程度)を返却しようと統合Mockを設定したところ見知らぬエラーが表示されたのです。
※そんなデータ、JSONで返却するなよ...というツッコミはとりあえず置いておいて下さい(笑)

エラーメッセージ:The resource being saved is too large.Consider reducing the number of modeled parameters, the number of response mappings, or perhaps the size of your VTL templates if used. 何だコレ...と思っていたのですが、どうやらAPI Gatewayにはマッピングテンプレートに設定容量制限があり、そこに引っかかったようです。
私の環境では、マッピングテンプレート128KB辺りを境界にエラーが発生しているようでしたが、公式ページ上のAPIGatewayの制限事項にドンピシャの記述は見つけられませんでした。
ですが、恐らく、この中の何かの制限に引っかかっているものと思います。
公式:REST API の設定および実行に関する API Gateway クォータ

それじゃあ、サイズの大きいデータはどうやって返せば良いのか...
Lambdaを使用することも可能ですが、出来るだけ簡単に対応したいと思ってAPI Gatewayを使用してMockを作成したのに構成が複雑になったら使う意味g(以下略
調査の結果「S3に配置したJSONファイルを直接返却する方法」が一番簡単そうでした。
実際に試すと6MBまでのデータであれば対応可能で、今回の私の要件は満たせそうです。

ということで、 今回はAPI GatewayからS3に直接アクセスしてJSONを返却する方法をご紹介します!

設定手順

以下の作成が完了している前提で、説明割愛いたします。
・S3バケットの作成
・API Gatewayで任意の統合Mock作成(→詳細はコチラで紹介しています!)

Step1: IAMロール作成

デフォルトの状態ではAPI GatewayにS3へのアクセス権限は付与されていません。
そのため、API GatewayにS3へのアクセス許可を与えるためのロールを作成します。

AWSコンソールからIAMの管理画面を開きます。
アクセス管理のロールを選択し、表示された画面右上にある[ロールを作成]を選択して下さい。
[ロールの作成]画面が表示されたら、[信頼されたエンティティの種類を選択]で「AWSサービス」が選択されていることを確認します。
[ユースケースの選択]で[S3(一覧)]>[S3(画面下部)]を選択し、[次のステップ:アクセス権限]ボタンをクリックします。
※今回は、信頼されたエンティティとして「API Gateway」を設定したいのですが、「API Gateway」として用意されているユースケースは、API GatewayからCloud watchにログをアップするというもので、S3へアクセスするユースケースは用意されていないようでした。そのため、本ユースケース設定をベースにカスタマイズします。

次画面の[Attach アクセス権限ポリシー]で、一覧から「AmazonS3ReadOnlyAccess」(※)を選択し、[次のステップ:タグ]ボタンをクリックしてください。
※今回はMockとして使用するため、リクエストがPOSTやPUTであっても、S3上のオブジェクトを読み込みレスポンスとして返却するのみです。そのため、必要最低限である読み取り権限のみのポリシーを選択しています。
タグの追加画面では特に設定変更を行わず[次のステップ:確認画面]をクリックし、確認画面まで遷移してください。
確認画面では以下2項目を設定し、[ロールの作成]ボタンをクリックすれば、ロールが作成されます。

【設定値】
・ロール名:任意
・ロールの説明:任意 ※今回だと「Allows API Gateway to access to S3.」とかでしょうか。

Step2: IAMロールのカスタマイズ

ロールを作成した段階では、ユースケースで選択したとおり「Allows S3 to call AWS services on your behalf.」という、S3が、このロールを使用して任意のAWSサービス(今回はS3)を呼び出すことを想定したものとなっています。
そのため、信頼関係を変更することで「S3」ではなく「API Gateway」が、このロールを使用できるようにします。

作成したIAMロールの概要ページに遷移し、[信頼関係]タブの[信頼関係の編集]ボタンをクリックして信頼関係の編集画面を開きます。
7行目の"Service": "s3.amazonaws.com"の記述を"Service": "apigateway.amazonaws.com"に書き換え、[信頼ポリシーの更新]ボタンをクリックして信頼関係を変更します。
これでAWSサービス「API Gateway」が、該当ロールとして振る舞うことを許可(信頼)されました。

信頼関係の変更後、IAMロールの設定状態を確認します。

[アクセス権限]タブ
「AmazonS3ReadOnlyAccess」権限のみが付与されていることを確認します。
[信頼関係]タブ
信頼されたエンティティに「IDプロバイダー apigateway.amazonaws.com」が設定されていることを確認します。

これで今回使用するロールの作成が完了しました。

Step3:S3にレスポンスファイルを配置する

S3バケットを任意で作成後、管理コンソールからレスポンス用のファイルをアップロードします。
今回は、JSON形式で返却したいため、以下のようなファイルを準備しました。
    {      "result_code" : "0",     "result_message" : "OK"     }

Step4:API Gatatewayの設定を変更する

S3オブジェクトによるレスポンスに変更したい、API Gateway上のリソースを選択します。
[メソッドの実行]画面で[統合リクエスト]をクリックし、設定画面で以下のように設定を変更して[保存]をクリクします。

【設定値】
 ・統合タイプ:AWSサービス
 ・AWSリージョン:ap-notheast-1
 ・AWSサービス:Simpe Storage Service(S3)
 ・AWSサブドメイン:指定なし
 ・HTTPメソッド:GET 
 ・アクションの種類:[パス上書きの使用]にチェック
 ・パス上書き:レスポンス用ファイルのパス ※バケット名/オブジェクト名
 ・実行ロール:今回用に作成したロールを指定(arn:aws:iam::xxxxx)
 ・コンテンツの処理:パススルー※デフォルト設定
 ・デフォルトタイムアウトの使用:チェックオン※デフォルト設定
AWSの統合に切り替えてよいかの確認ダイアログが表示されますので、[OK]をクリックしてください。
これで設定変更は完了です。

Step5: 動作確認&APIのデプロイ

実際に動くかどうか確認を行います。
[メソッドの実行]画面で画面右にある[テスト⚡]をクリックし、[メソッドテスト]画面に遷移します。
[メソッドテスト]画面の左下にある[⚡テスト]ボタンをクリックして、テスト実行してください。
リクエストのレスポンス本文にS3オブジェクトに配置したとおりの内容が返ってきていればOKです。

ここまで確認ができれば、APIのデプロイを行い、作業終了です。
実際にMockとして呼び出して、テストしてみてください。

おまけ:レスポンスに画像を指定する

JSONだけではなく画像ファイルを返却したいという場合もあると思います。
今の設定でも、S3上のファイルを画像ファイルに変えれば上手くいくんじゃないの?って感じるかもしれませんが、デフォルトで画像ファイルのレスポンスは制限されているようです。
以下設定手順を軽くご紹介しておきます。

設定手順

該当APIの設定メニューを選択し、設定画面を開きます。
設定画面の下部にある「バイナリメディアイプ」の[+バイナリメディアタイプの追加]を押下することで、入力フィールドが追加されるため、「*/*」(※)を入力して、右下の[変更の保存]を押下して完了です。
※指定する内容は、HTTPヘッダーのContent-Typeと同様の内容でも問題ありませんが、ワイルドカードで指定しておくのが一番楽ちんかと思います。
これでS3の画像オブジェクトを指定しても、問題なくレスポンスできるようになったかと思います。

まとめ

こちらの方が従来のMockツールに近い感じですね。
また、S3のファイルを置き換えれば、レスポンス内容変更のたびにAPIをデプロイし直すという手間も省けて楽な感じがします。
ガッツリテストをするとなると、こちらの方が向いているかな...というのが個人的な感想です。

API Gatewayには、いろいろな使い方があるのがわかってきたので、もっと色々と紹介していきたいと思います!


, ,