20210514のReactに関する記事は8件です。

[React] react-draft-wysiwygを用いたリッチテキストエディタ

実行環境 MacOS BigSur -- 11.2.1 npm -- 6.14.4 react -- 17.0.1 react-dom -- 17.0.1 react-hook-form -- 7.0.0 draft-js -- 0.11.7 draft-convert -- 2.1.11 react-draft-wysiwyg -- 1.14.7 dompurify -- 2.2.8 リッチテキストエディタを実装したい Reactアプリにリッチテキストエディタの実装をしたい、と考えたところFacebook製のDraft.jsが良いという記事をいくつか見ました。 今回は、こちらで紹介されていたDjaft.jsのライブラリであるreact-draft-wysiwygを使用して実装してみました。 エディタ基本実装 まずはライブラリをインストールします。 $ npm install draft-js $ npm install react-draft-wysiwyg 公式ドキュメントのチュートリアルにあるコードを元に、まずは簡単なエディタを作成します。 editor.js import React, { Component } from 'react'; import { Editor } from 'react-draft-wysiwyg'; //node_module内のcssファイルを読み込む import '../../node_modules/react-draft-wysiwyg/dist/react-draft-wysiwyg.css'; const Editor = () => { <Editor wrapperClassName="wrapper-class" editorClassName="editor-class" toolbarClassName="toolbar-class" wrapperStyle={<wrapperStyleObject>} editorStyle={<editorStyleObject>} toolbarStyle={<toolbarStyleObject>} toolbar={{ options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'colorPicker', 'link', 'history'], inline: { inDropdown: true }, list: { inDropdown: true }, textAlign: { inDropdown: true }, link: { inDropdown: true }, history: { inDropdown: true }, blockType: { options: ['Normal', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'Blockquote', 'Code'], }, }} localization={{ locale: 'ja', }} /> } export default Editor![スクリーンショット 2021-05-14 23.33.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681173/3e400e93-9688-3f49-5bc0-f1aee0e4e849.png) ; まずはツールバーとエディタが表示されます。 ちなみに、node_module内のcssファイルを読み込む際には、自分のプロジェクト構成からパスを設定する必要があります。 toolbar = {} の部分でツールバーの設定を行います。公式ドキュメントにそれぞれの概要が書いてありますので、編集しましょう。 localization = {{locale: 'ja',}} の部分で日本語に設定できます。この設定をしない場合は、ツールバーの各表示がDefaultの英語表記になっています。 CSS編集 エディタの見た目の調整は、以下を記述することで可能です。 wrapperClassName="wrapper-class" editorClassName="editor-class" toolbarClassName="toolbar-class" index.css .wrapper-class { padding: 1rem; margin-left: 1rem; margin-right: 1rem; border: 2px solid #ccc; } .editor-class { background-color:lightgray; padding: 1rem; border: 2px solid #ccc; } .toolbar-class { border: 2px solid #ccc; } このようにcssファイルで各要素のスタイルを変更することで、上記のような出力になります。 リッチテキストの表示 通常エディタを実装する場合は入力した内容を保存・出力する必要があります。先程の表示させただけのエディタから、その入力内容を表示する実装を行います。 エディタの状態 エディタ内部の状態管理について、editorStateとonEditorStateChangeがあります。 editorState - エディタの状態を更新する onEditorStateChange - エディタの状態が変化したときに呼び出される関数 例えば、初期状態を以下のように定義してeditorStateに設定すると、エディタ表示時に初期テキストが表示されます。 editor.js ... const initData = convertFromRaw({ entityMap: {}, blocks: [ { key: "xxxxxx", // ユニークなキー値 text: "ここに初期テキストがはいります。", // 任意のテキスト type: "unstyled", // テキストのタイプ。初期値は "unstyled" depth: 0, entityRanges: [], inlineStyleRanges: [], data: {}, }, ], }) const initState = EditorState.createWithContent(initData,); const [editorState, setEditorState] = useState(initState); return( <div> <Editor ... editorState={editorState} /> )} データ変換 テキストの保存、出力のために必要となるデータ変換を行うために、Draft.jsは以下の3つの関数を提供してくれているらしいです。 ContentFromRaw - raw state(RawDraftContentState)をContentStateに変換 ContentToRaw - 上記の逆変換 ContentFromHTML - HTMLをContentBlockオブジェクトの配列、entityMapオブジェクトに変換 他にもエディタ内の状態をHTMLに変換する必要がありますが、これにdraft-convertを利用します。 $ npm install draft-convert このパッケージにより、convertToHTML関数を使用してHTML変換が可能となります。 HTMLのサニタイズ さて、後はHTML変換したものをページ上に表示させるだけですが、HTMLをページに追加する前にHTMLが適切に構造化及びサニタイズ(危険なコードやデータを変換または除去して無力化する処理)されているかチェックする必要があります。この処理を怠ると、クロスサイトスクリプティング(XSS)の危険性が高まります。 この処理を簡単に実行してくれるのが、dompurifyパッケージです。 $ npm install dompurify ちなみに、このpurifyという英単語に馴染みがなく、Google翻訳すると「祓い清める」と変換されてました大袈裟な気が。。笑 何はともあれ、dompurifyによりサニタイズされたHTMLを取得できます。 実装及び表示確認 実際の実装手順を整理します。 1. エディタに入力されている現在のコンテンツの取得  getCurrentContentメソッド - エディタの現在のコンテンツを取得 2. HTMLへ変換  setConvertContent 3. テキストの表示  dangerouslySetInnerHTML - HTMLが適切に構造化及びサニタイズされているか確認 Editor.js import React, { useState } from 'react'; import { EditorState,convertFromRaw } from 'draft-js'; import { Editor } from 'react-draft-wysiwyg'; import DOMPurify from 'dompurify'; import { convertToHTML } from 'draft-convert'; import '../../node_modules/react-draft-wysiwyg/dist/react-draft-wysiwyg.css'; const Editor = () => { const initData = convertFromRaw({ entityMap: {}, blocks: [ { key: "xxxxxx", // ユニークなキー値 text: "ここに初期テキストがはいります。", // 任意のテキスト type: "unstyled", // テキストのタイプ。初期値は "unstyled" depth: 0, entityRanges: [], inlineStyleRanges: [], data: {}, }, ], }) const initState = EditorState.createWithContent( initData, ) const [editorState, setEditorState] = useState(initState); const [convertedContent, setConvertedContent] = useState(null); const handleEditorChange = (state) => { setEditorState(state); convertContentToHTML(); } const convertContentToHTML = () => { let currentContentAsHTML = convertToHTML(editorState.getCurrentContent()); setConvertedContent(currentContentAsHTML); } const createMarkup = (html) => { return { __html: DOMPurify.sanitize(html) } } return( <div> <Editor editorState={editorState} onEditorStateChange={handleEditorChange} wrapperClassName="wrapper-class" editorClassName="editor-class" toolbarClassName="toolbar-class" toolbar={{ options: ['inline', 'blockType', 'fontSize', 'list', 'textAlign', 'colorPicker', 'link', 'history'], inline: { inDropdown: true }, list: { inDropdown: true }, textAlign: { inDropdown: true }, link: { inDropdown: true }, history: { inDropdown: true }, blockType: { options: ['Normal', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'Blockquote', 'Code'], }, }} localization={{ locale: 'ja', }} /> <div className="preview" dangerouslySetInnerHTML={createMarkup(convertedContent)}></div> </div> )} export default Editor; このように、エディタに入力した内容を正しく下の部分に表示できています!! まだまだ勉強すべきことがたくさんありますが、とりあえず簡単なエディタ操作ができました。それにしてもreact-draft-wyswygの日本語記事が少なすぎる。。。リンクや画像、文字色などにはまだ対応できていないので学習を続けようと思います。 参考 以下のページが非常に分かりやすく、参考にさせていただきました。 - https://blog.logrocket.com/building-rich-text-editors-in-react-using-draft-js-and-react-draft-wysiwyg/ - https://www.to-r.net/media/draftjs-tips/ - https://qiita.com/so99ynoodles/items/5b4f237e03dadc42e751
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Firebaseを使わずにブラウザ上でローカル画像をリサイズ【canvasのdrawImage()をReactで利用した方法】

Firebase側で画像のサイズを変えることはできますがFirebase側への通信量を抑えたいと思ったのでブラウザ上でサイズを小さくすることにしたことのまとめです。 プロフィール画像を想定していて画像の最大幅がある程度は分かっていた流れがあります。 成果物 アップロードされた画像は canvas の drawImage() でリサイズされたものです。 ソースコード App.js import { useState } from "react"; import "./styles.css"; const uploadToFirestore = () => { console.log("画像をuploadしました"); //ダミー処理 }; const App = () => { const [uploadImg, setUploadImg] = useState(""); const handleUploadImg = (e) => { if (!e.currentTarget.files || e.currentTarget.files.length === 0) return; const fileReader = new FileReader(); const file = e.currentTarget.files[0]; if (file.type !== "image/jpeg" && file.type !== "image/png") return; fileReader.onloadend = () => { const fileImage = new Image(); fileImage.onload = () => { const uploadWidth = fileImage.width, uploadHeight = fileImage.height; const wantWidth = uploadWidth < 200 ? uploadWidth : 200, resizeRatio = uploadWidth / wantWidth, wantHeight = uploadHeight / resizeRatio; const canvas = document.createElement("canvas"); canvas.width = wantWidth; canvas.height = wantHeight; let ctx = canvas.getContext("2d"); ctx.drawImage(fileImage, 0, 0, wantWidth, wantHeight); ctx.canvas.toBlob( async (blob) => { const resizedImageFile = new File([blob], file.name, { type: file.type, lastModified: Date.now() }); const blobToUrl = URL.createObjectURL(blob); //blobをimgのsrc属性で使える形へ変換 setUploadImg(blobToUrl); //リサイズした画像を表示 uploadToFirestore(resizedImageFile); //resizedImageFileをFirestoreへ }, file.type, 1 ); }; fileImage.src = fileReader.result; }; fileReader.readAsDataURL(file); }; return ( <div className="App"> <h1>React画像アップロード</h1> <input type="file" onChange={(e) => handleUploadImg(e)} /> <div style={{ margin: "10px" }}> <img src={uploadImg} alt="アップロード画像" /> </div> </div> ); }; export default App; codesandbox URL: https://codesandbox.io/s/react-img-size-change-canvas-n1iln?file=/src/App.js:0-1967 コード解説 概要 ・FileReaderインスタンスでアップロードした画像を取り出す ・Imageインスタンスを呼び出しcanvasメソッドを使ってリサイズする ・fileImage.onloadの中身のコードについて FileReaderインスタンスでアップロードした画像を取り出す App.js // 省略 const fileReader = new FileReader(); const file = e.currentTarget.files[0]; // 省略 fileReader.readAsDataURL(file); FileReaderインスタンスであるfileReaderの中のreadAsDataURL(file)を読むことでfileReder.onloadを発火させます。 ここの詳細については前回の記事、Reactで画像アップロード機能を実装してローカルファイルを読み込ませるで書いています。 Imageインスタンスを呼び出しcanvasメソッドを使ってリサイズする App.js // 省略 const fileImage = new Image(); fileImage.onload = () => { // 省略 } fileImage.src = fileReader.result; // 省略 } ImageインスタンスであるfileImageのsrcに画像データ(blobデータ)を代入するとonloadが走ります。 ちなみにfileReader.resultの中身を imgタグ の src属性 に入れても読み込むことができます。 fileImage.onloadの中身のコードについて App.js // 省略 fileImage.onload = () => { //読み込んだ画像からサイズを取り出す const uploadWidth = fileImage.width, uploadHeight = fileImage.height; //幅200を超える画像は全て200へリサイズする。そうでない場合はリサイズしない。wnatWidthはリサイズしたいサイズのこと。 const wantWidth = uploadWidth < 200 ? uploadWidth : 200, resizeRatio = uploadWidth / wantWidth, wantHeight = uploadHeight / resizeRatio; //canvasメソッドを使ってサイズ指定してdrawImage(略)でレンダリング const canvas = document.createElement("canvas"); canvas.width = wantWidth; canvas.height = wantHeight; let ctx = canvas.getContext("2d"); ctx.drawImage(fileImage, 0, 0, wantWidth, wantHeight);      //canvasデータをblobデータへ変換し、受け取った結果を元にイメージファイルであるresizedImageFile変数を作る ctx.canvas.toBlob( async (blob) => { const resizedImageFile = new File([blob], file.name, { type: file.type, lastModified: Date.now() }); const blobToUrl = URL.createObjectURL(blob); //blobをimgのsrc属性で使える形へ変換 setUploadImg(blobToUrl); //リサイズした画像を表示 uploadToFirestore(resizedImageFile); //resizedImageFileをFirestoreへ }, file.type, 1 ); }; // 省略 コードへ注釈を入れていますがそのままの通りになります。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React×Amplifyでブログサイトを作成

React、TypeScript、ReduxToolKit、Amplifyの学習の一環と自前のブログサイトが欲しいと思いがあったので、実際にブログサイトを作ってみました。 仕様技術/ライブラリー React TypeScript Redux(ToolKit) Amplify、Appsync、AWS GraphQL chakra-ui react-share react-simplemde-editor UI画像一覧 一般ユーザーのHome画面 一般ユーザー記事詳細画面 ログイン画面 管理者側記事詳細画面 (編集と削除ボタンあり) 記事編集画面 新規投稿画面 仕様 一般ユーザーページ ①記事一覧画面 ②記事詳細画面 記事詳細画面トップ画像、作成日、更新日、コメント数、タイトル、本文、コメント機能、SNSシェア機能を実装。 コメント機能は記事に対してフィードバックが貰えたら嬉しいなという思いで実装しました。 (コメントするのにログインは必要なく、名前を記入しなくとも大丈夫です。) 管理者ユーザーページ ①ログインページ UIは自前ですが実際のログイン機能はAmplifyのAuthを使用。 サインアップはできないようにログイン機能のみを実装。 ②記事一覧画面 基本的には一般ユーザーと同様です。 ③記事詳細画面 基本的には一般ユーザーと同じ仕様ですが管理者側には編集と削除ボタンがあり。 (同じコンポーネントを使用してログインしているかどうかでボタンを表示したり非表示したりしています。) ④記事編集画面 記事の編集を行えます。 ⑤新規投稿画面 マークダウン形式で記事の投稿、トップ画像の設定が可能。 (画像を設定しない際にはデフォルトでNO_IMAGEとなります) またリロードしても内容が消えないようにローカルストレージを使用して一時保存可能。 Reduxでユーザー情報、コメント情報、投稿情報を管理させています。 DBはAmplify経由でDynamoDBを利用、GraphQLでAPIを叩いています。 制作を通して苦労した点 GraphQLのスキーマ定義にかなり苦戦しました。 特にauthの理解が乏しく最後の最後にログインしていないユーザーがコメントできないという事象が発生しました。 この原因が権限を userPoolsにしか付与していなかったことでした。なのでログインしていないユーザーにも権限を付与するためにGraphQLのスキーマを再定義してなんとか解消しました。GraphQLとAWSのインプットとアウトプットをもっと行い知識として定着させたいです。 制作を通しての感想 chakra-uiが便利で感動しました..。レスポンシブを容易に組めるのでUI構築がサクサクと進んでかなり効率的にアプリの作成ができました。 以前まで苦労していたTypeScriptはかなり慣れることができました。 この作品を作る前に別のアプリを作った際にTypeScriptを導入しませんでした。その時は思わぬところでエラーが出たりと苦戦することが多かったのでやっとTypeScriptの恩恵を得られるようになりました。 参考サイト https://qiita.com/too/items/cb1dfb4f44536a3e9855 https://qiita.com/kazuhiro1982/items/b1183103f41b8e976f6a https://qiita.com/Engineer_Grotle/items/fa37a3924d1e66082889
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReduxとRedux ToolKitを比較

Reactでアプリを作成する際に皆さんは状態管理ライブラリーは何を使用されていますか? 私はほとんどの場合はRedux ToolKitを使用しています。 おそらく個人開発レベルでReduxを導入するのはどうなのかと疑問を持つ方が多いのかなと思います。 少し前は私も個人開発ではpropsのバケツリレーで良いのでは?Reduxもかなり複雑で扱いにくいしと思っていました。 しかしRedux ToolKitというライブラリーを知ってから便利さゆえにどんな小さいアプリにも導入してしまいます。 そこで生のReduxとRedux ToolKitを比較してRedux ToolKitの有り難さを改めて実感したいと思います! そもそもReduxとは? Reduxは状態を管理するためのライブラリーです。 開発の規模が大きくなるほどに開発が容易になるので恩恵を受けることができる。 例えば素のReactでは親コンポーネントの情報を10階層にある子コンポーネントに渡したい際には、その親コンポーネントと10階層目の子コンポーネント内にある全てのコンポーネントでpropsのバケツリレーをする必要があります。また、新たな情報を渡したい際には全ての子コンポーネントを書き換える作業が発生します。このような作業はミスやエラーの温床になり兼ねない状態です。 ではReduxを導入するとどうなるのか。 開発者は状態管理とUIを分離して実装することができます。先程の例で言うと10階層目のコンポーネントにReduxをコネクトするので、他のコンポーネントで状態のバケツリレーをする必要はなくなります。 上記のように便利に状態管理するためにReduxには大きく三つの機能があります。 一つずつ概要を説明していきたいと思います。 action creator(action) store reducer 1. action creator(action) ユーザーがインプット/アクションをした際にActionCreatorにAction制作の依頼が飛びActionが作成されます。 Actionを生成するだけではStore内のStateを更新することは出来ません。なので、ActionCreatorで生成されたActionをdispatchしてstoreに変更情報を送ります。 2. store Storeはアプリケーションの全ての状態が集まっている場所です。 Storeはアプリケーション内で一つ存在し、一つの状態を保持しています。 状態などの情報が欲しい際にはこのstoreが受け渡しを行っています。 3. reducer ReducerによってStore内のStateを更新する役割があります。引数のstateを更新するのではなく、新しいstateのオブジェクトを作成して返しています。 ReduxToolKitとは? Reduxを簡単に実装するためのライブラリーです。 またTypeScript製なのでTypeScriptとの相性がとても良い特徴があります。 ReduxとRedux ToolKitを比較 生のReduxは必要最低限の機能しかないため、色々なモジュールを各自で揃える必要があります。それらを初学者が実装するにはかなりハードルが高いです。またReduxに慣れてきた人にとっては面倒に感じたりするのではないでしょうか。 Redux ToolKitではそのような煩雑な部分を解消してくれます。 初期設定 以下のmiddlewareが初期段階で組み込まれています。 immutable-state-invariant serializable-state-invariant-middleware Redux Thunk これらのmiddlewareが既に組み込まれているので初期設定が大分楽になり効率よくReduxの実装をすることでできます。 createSlice関数でaction creator(action)のコードが不要 createSlice()の中でreducerを実装する中で自動的にaction creator(action)が生成されます。 テンプレートがある テンプレートがあるので、そのテンプレートを自分で色々と触って手軽に一連の動作を学ぶことができる。 以下がテンプレートのURLです。 https://github.com/reduxjs/cra-template-redux-typescript  最後に 私自身まだプログラミング初学者なのでReduxの恩恵をふんだんに味わえるような開発はできていないです。 しかし、個人開発の小さいアプリに導入するだけでかなり楽に状態管理をすることができています。 これからReduxに手を出す方に個人的にお勧めしたいのはRedux ToolKitのテンプレートを触ることです。 言葉では理解できないところが実際に触ることで理解できたりすると思います。 参考サイト
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人開発】Amazon S3に格納した音楽だけを再生するマニアックな音楽プレイヤーを、React + Typescript + AWS Amplify で作った話

はじめに 最新の技術動向のキャッチアップを目的に、Amazon S3に格納した音楽だけを再生するWebサービスBucketifyを作成しました。 ある程度形になったので、「多分動くと思うからリリースしようぜ」の精神で公開してみます。 筆者は普段は事業会社で社内SEとして、社内利用の業務システムの開発/保守をしており、 フロントエンドの技術やtoC向けの開発と縁遠いところから、個人開発でWebサービスを作るにあたり色々とあった挫折やチャレンジ、得たものなどについて本記事にまとめました。 同じような境遇でこれから個人開発をする方の手助けになれば幸いです。 作ったもの Amazon S3に格納した音楽を再生するWebサービスBucketifyです。 ユーザが管理するS3バケット名と、認証情報(アクセスキーとシークレットアクセスキー)をBucketifyに入力し、バケットのスキャンを行うと、Bucketifyが音楽ファイルの「メタデータ」と認証情報を管理し、音楽を再生できるようになります。 アーキテクチャ Amplifyで全体を管理し、認証はCognito、DBはDynamodbを使用したサーバレス構成です。 ソースコードはGitHubに公開しているので興味があればご覧ください。 機能 自動生成されるライブラリ トラック別、アーティスト別でライブラリを自動生成します。 対応している音声ファイルの拡張子は'mp3'、'm4a'です。 ストリーミング再生 S3バケットに保管した音楽ファイルをストリーミング再生できます。 スマートフォンでバックグラウンド再生にも対応しています。 こだわり ダーク/ライトモード選択、他言語選択機能 オープンソース 仕組み上、ユーザからAWSのAccessKey,SecretAccessKeyを提供してもらう必要があります。 得体の知れないアプリにそれを預けるのは怖くて自分だったら使いたくないと思い、また、Amazon S3に音楽を保存するようなエンジニアがターゲットなので、ソースコードを公開しています。 使用技術など フロントエンド フロントは「React + Typescript」で作っています。 Reactを選んだ理由 個人開発なのでインフラの運用コストを下げたく、Webサーバを立てずにSPA(ReactかVue)をCloudFrontで配信しようと考えており、選定にあたり、以下の記事が参考になりました。 ReactとVueのどちらを選ぶか Javascript自体あまり1からガリガリと書いたことがなく、原理的なところからおさえたかったこと Typescriptとの相性がよく、Typescriptが書けるようになりたかったこと React Nativeでモバイル開発もできること 以上の理由からReact + Typescriptで作ることを決めました。 状態管理 Reduxは入れず、ReactのHooksのみを使用しました。 Stateを下のコンポーネントに対してProps渡しする地獄を回避するため、当初はReduxを使用しようとしていましたが、 ファイル数が多くなり、やろうとすることに対して複雑性だけが増している印象を受けました。 そうこうしているうちにReactのHooksが台頭してきて、 コンポーネントを跨ぐ状態管理に「createContext/useContext」のHooksを使ってみたところ、 小規模なアプリでは、それだけで十分だと感じました。 デザイン UIフレームワーク MATERIAL-UIを使用しています。 非常に多くのコンポーネントが用意されており、必要なコンポーネントを組み合わせて実装していくことで、比較的少ない工数でモダンチックな画面の構築ができたと思います。 コンポーネント同士の依存関係の管理にはAtomicDesignを参考にしました。 ただし、実装していてAtomsとMoleculesを細かく明確に分けていくのが途中から面倒になり、上記のように二つは同じ粒度・階層で管理をするようになりました。。 この程度の個人開発アプリであれば、それでも特に問題なかった印象ですが、プロジェクトが大きくなるほど、AtomicDesignのコンポーネントの再利用性が求められてくると感じ、SPAのプロジェクト構成管理のベストプラクティス/ノウハウについてもっと身につけたいなと感じました。 ロゴの作成 hachfulというサービスを使用しました。 シンプルなものが多いですが、無料で商用利用できるロゴを簡単に作成可能です。 学習方法 ES6のJavascriptは書いたことがなく、ReactもTypescriptも触ったことがなかったので、まずは以下で学習を進めました。 JavaScript 「再」入門を読む React公式チュートリアルの実施 ⭕️❌ゲームを作るチュートリアルで、JSXや状態管理の概念を学べます 公式チュートリアルに加えて、有料ですがProgateのトレーニングも実施しました Javascript React freeCodeCamp 英語が問題なければこちらは無料なのでおすすめです。 バックエンド Dynamodb データストアにはDynamodbを使用し、Dynamodbへのデータ取得にはGraphQL(AppSync)を使用しています。 技術選定には以下の記事が大変参考になりました 個人開発・スタートアップで採用すべき最強のアーキテクチャを考えた 個人開発で作成したアプリで利用者がまだいない状態でも、継続的にサーバの利用料金の固定費を払いたくなく、 サーバレス構成で構築し、データストアにはDynamodbをオンデマンドモードで使用しています。 AWS Amplify Amplifyで以下を管理しています。 - Webサイトのホスティング - CI/CD - ログイン認証(Cognito) 今回の開発で一番驚きだったのは、Amplifyでした。 もともとモバイルバックエンドのイメージしかありませんでしたが、SPAのバックエンドに使えると知り、こちらでバックエンドを管理したところ、フレームワーク側で用意している機能が充実しており大幅に自前で作るものが減りました。 Cognito連携でAmplify用のReactコンポーネントが用意されているため、ログイン関連画面は一切作る必要がなく、 CI/CDやサイトのホストまで、Amplify CLIで一撃でした。 学習方法 Dynamodb設計 DynamodbのDB設計には、NoSQL最適の設計をしたことがなかったため非常に苦労しましたが、以下が大変参考になりました。 特に、GSIオーバーローディングの考えはRDBには無く、目から鱗でした。 0190905_イチから理解するサーバーレスアプリ開発-サーバーレスアプリケーション向きのDB 上記を参考に以下を一つずつ整理していきました。 ユースケースの洗い出し 必要なテーブルの項目とインデックスを定義 定義したテーブルとインデックスで、ユースケースを満たす問い合わせができるかを検証 結果として今回はGSIオーバーローディングを採用しましたが、データを縦持ちすることで必要データを取得するために問い合わせの数も増えました。 GSIを複数作成した場合のストレージなどのコストと、GSIをオーバーローディングする事で増える、オンデマンドのWCU、RCUのコストはトレードオフの関係にあり、 格納するデータの項目数や1レコードのサイズ、アクセスパターンなどから、適切に選択していく必要があると感じました。 AWS Amplify 以下の公式チュートリアルでサンプルのアプリやGitHubからのCI/CDを作るところから始めました。 Getting started - Amplify Docs ReactJS アプリケーションをデプロイしてホストします Dynamodb + GraphQLのAPI追加、認証機能の追加、デプロイ、と順を追ってサンプルアプリを作る構成で、 Amplifyで何ができるのかというところを理解できます。 GraphQL GraphQLとは何なのかを色々と調べた後、順番としてはAmplifyチュートリアルを先にやり、その中で初めてGraphQLを触りました。 スキーマを定義すると、AmplifyがGraphQLの生成までしてくれるため、1から作らなくていい分なかなか理解が進みませんでした。 そこで一旦AppSync(AWSのGraphQLのマネージドサービス)単体で、公式の以下のドキュメントなどを参考に、画面でAppSyncコンソールからクエリを投げてみたりして、GraphQL、スキーマ、リゾルバ、データストアなどの関係を掴みました。 AWS AppSync クイックスタート AWS AppSync チュートリアル Dynamodb リゾルバー プロジェクト管理 Notionでカンバンボードを利用して管理しました。 特にリリースの期日も決めていなかったので、タスクだけ思いつくままに上げ出していき、ひとつずつタスクずつ消化していきました。 仕事やプライベートが忙しくなりしばらく期間が空くと、何をどこまでやっていたかわからなくなり、そのままお蔵入りすることもあったので、 個人開発でも、最低限のタスク管理だけは入れて正解でした。 その他使用したライブラリ react-router-dom ルーティングで使用 music-metadata-browser 音楽ファイルからメタデータの抽出に使用 react-scroll-parallax parallax(スクロールの視差エフェクト)で使用 react-infinite-scroller トラック画面の無限スクロールに使用 react-spring アーキテクチャの画像をonhoverで傾ける効果に使用 react-share SNSシェアリンクを簡単に設置できます react-i18next 他言語対応をjsonで定義した翻訳ファイルをもとに簡単に実装できます。 終わりに これまで使用したことのない技術ばかりだったので、初めはなかなかスピードが出ませんでしたが、 一通り作り終えてみると、ReactがTypescriptでなんとなく書けるようになり、 GraphQLやDynamodb設計の考え方が身につき、大変勉強になりました。 今後の課題としては、今回の開発ではテストを全く書かなかったため、 次作るものはテストを書いて、個人開発でも継続的に改善できるアプリケーションをテーマに開発していきたいです。 最後まで読んでいいただきありがとうございました。 この記事が、これから何かを作ろうとする方の後押しになると幸いです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React × Stripe × Netlify Functionsでサーバーレスな決済基盤を持ったECサイトを作ってみる

概要 個人で開発しているサービスに決済機能を追加したく、どのサービスを使おうか迷っていたのですが、Stripe(https://stripe.com/jp )を使ってみたところ手軽にセキュアな決済機能を実装する事ができたのでメモ書き。 完成イメージ https://simple-e-commerce-20210513.netlify.app 使用技術 React TypeScript Stripe NetlifyFunctions 事前知識 具体的な実装に入る前に、Stripeの仕様についていくつか押さえておかなければならない点があるので軽くまとめておきます。 3種類の組み込み方法 現在、Stripeには以下3種類の組み込み方法があり、それぞれ特徴が異なります。 Stripe Checkout Charges API Payment Intents API 参照: https://stripe.com/docs/payments Stripe Checkout Stripeが用意した専用のサーバーに誘導して決済を行ってもらう形式。決済後は自サービスへリダイレクトさせる。 イメージ こんな感じでいかにもといった感じの良さげなページを少量の記述で準備する事ができます。 メリット 実装がシンプル(自前でサーバーを用意する必要が無く、処理の大部分をStripeに丸投げする事ができる)。 Stripeが提供している決済機能だと一目でわかるので利用者目線だと安心?(まだサービスが無名なうちとかは特に) デメリット 後述の2種類に比べるとカスタマイズ性はやや劣る。 参照: https://stripe.com/docs/payments/checkout Charges API & Payment Intents API 自前でサーバーを用意し、自サービス内で決済を完了させる形式。 イメージ Stripe Checkoutとは打って変わりUIや決済処理をほぼ全て自前で実装していく事になるため、カスタマイズ性という面では融通が効きそうです。 今回はサンプルアプリなのでだいぶ適当な感じですが、Stripeが提供している「Stripe Elements」を上手く組み合わせる事で多様な決済画面が作れると思います。 メリット そのほとんどを自前で実装する事になるため、カスタマイズ性に優れる。 デメリット 実装コストが高い(前述のようにクライアントだけでなくサーバーの実装も必要になるため)。 なお、「Charges API」と「Payment Intentes」の主な違いについては以下を確認してください。 参照: https://stripe.com/docs/payments/payment-intents/migration/charges 参照: https://stripe.com/docs/payments/charges-api ざっくり言うと「Charges API」が旧式、「Payment Intentes API」が新式に該当するようです。 セキュリティ面などで後者の方が優れるため、できるだけ後者を使った方が良いように思えますが、より詳細な部分を見ていくと使用可能な決済方法が微妙に違うなどそれぞれ一長一短もあるようです。(つまり適切な使い分けがベター?) もっとも、公式の見解によると今後新機能が追加される場合は「Payment Intents API」のみが対象となるようなので、今回の記事ではこちらを使用した実装で進めていきます。 参照: https://stripe.com/docs/payments/payment-intents/migration/charges 実装 前置きはほどほどに実装していきましょう。 各種APIキーを準備 https://stripe.com/jp まだStripeのアカウントを持っていない場合はササっと作成してください。 ダッシュボードから「開発者」→「APIキー」と進み、「公開可能キー」と「シークレットキー」の2つをメモに控えておきましょう。 なお、今回はテスト環境用のAPIキーを使用してきます。 クライアント 次にクライアント側の実装に入ります。 create-react-app おなじみのコマンドで雛形を作成。 $ npx create-react-app simple-e-commerce --template typescript $ cd simple-e-commerce 不要なファイルを削除 この先使う事の無いファイルを削除しておきます。 $ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts それに伴い、以下の2ファイルを編集。 ./src/index.tsx import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./App" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) ./src/App.tsx import React from "react" const App: React.FC = () => { return ( <h1>Hello World!</h1> ) } export default App 動作確認 $ yarn start http://localhost:3000 にアクセスして「Hello World!」が表示されていればOKです。 各種ライブラリをインストール この先使用する事になるライブラリをまとめてインストールしてしまいます。 $ yarn add @material-ui/core @material-ui/icons @stripe/react-stripe-js @stripe/stripe-js stripe dotenv-webpack http-proxy-middleware netlify-lambda $ yarn add -D @types/stripe npm-run-all material-ui関連 UIを整えるために使用。 stripe関連 Stripeを利用するために使用。 dotenv-webpack 環境変数を取り扱うために使用。 http-proxy-middleware CORS対策のために使用。 netlify-lambda Netlify Functionsを利用するために使用。 npm-run-all npm-scriptsを一気に実行するために使用。 各種ディレクトリ・ファイルを作成 この先使用する事になるディレクトリ・ファイルをまとめて作成してしまいます。 $ cat > provisioning.sh << EOS #!/bin/bash mkdir src/components mkdir src/components/cart mkdir src/components/checkout mkdir src/components/layouts mkdir src/components/product mkdir src/data mkdir src/interfaces mkdir src/lambda touch src/components/cart/Cart.tsx touch src/components/cart/CartItem.tsx touch src/components/checkout/CheckoutForm.tsx touch src/components/layouts/Header.tsx touch src/components/product/Product.tsx touch src/data/products.ts touch src/interfaces/index.ts touch src/lambda/paymentIntents.js touch src/setupProxy.js touch src/setupProxy.ts touch .env touch netlify.toml touch webpack.functions.js EOS たくさんあって面倒なのでシェルスクリプトを作成して実行してください。 $ sh provisioning.sh 最終的に次のような構成になっていればOKです。 SIMPLE-E-COMMERCE ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── cart │   │   │   ├── Cart.tsx │   │   │   └── CartItem.tsx │   │   ├── checkout │   │   │   └── CheckoutForm.tsx │   │   ├── layouts │   │   │   └── Header.tsx │   │   └── product │   │   └── Product.tsx │   ├── data │   │   └── products.ts │   ├── lambda │   │   └── paymentIntents.js │   ├── interfaces │   │   └── index.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   ├── react-app-env.d.ts │   ├── setupProxy.js │   └── setupProxy.ts ├── .env ├── .gitignore ├── netlify.toml ├── package.json ├── provisioning.sh ├── README.md ├── tsconfig.json ├── webpack.functions.js └── yarn.lock 型を定義 まず最初にアプリ全体で使い回す型を定義しておきます。 ./src/interfaces/index.ts export interface Product { id: number title: string description: string price: number image: string } export interface CartItem { id?: number | undefined title?: string | undefined price?: number | undefined quantity: number cost?: number | undefined } 商品(Product) title: 商品名 description: 商品説明 price: 商品価格 image: 画像 カート内(CartItem) title: 商品名 price: 商品価格 quantity: 数量 cost: 合計金額(price × quantity) 商品データを作成 本来であればAPI(microCMSとか使うと良いかも?)などで別途管理すべき項目かもしれませんが、今回は単純化のために適当にJSONデータで保持しておきます。 ./src/data/product.ts import { Product } from "../interfaces/index" export const products: Product[] = [ { id: 1, title: "Banana", description: "Lorem ipsum dolor sit amet.", price: 100, image: "https://food-foto.jp/free/img/images_big/fd400883.jpg" }, { id: 2, title: "Apple", description: "Lorem ipsum dolor sit amet.", price: 200, image: "https://food-foto.jp/free/img/images_big/fd400993.jpg" }, { id: 3, title: "Orange", description: "Lorem ipsum dolor sit amet.", price: 300, image: "https://food-foto.jp/free/img/images_big/fd401266.jpg" } ] 各種ビューを作成 見た目の部分を作り込んでいきます。 index ./src/index.css body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } .StripeElement { display: block; margin: 0.5rem auto 1.5rem; max-width: 500px; padding: 12px 16px; font-size: 1rem; border: 1px solid #eee; border-radius: 3px; outline: 0; background: white; } App ./src/App.tsx import React, { useState } from "react" import { Elements } from "@stripe/react-stripe-js" import { loadStripe } from "@stripe/stripe-js" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import { products } from "./data/products" import Product from "./components/product/Product" import Cart from "./components/cart/Cart" import CheckoutForm from "./components/checkout/CheckoutForm" import Header from "./components/layouts/Header" import { CartItem as CartItemType } from "./interfaces/index" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) const App: React.FC = () => { const classes = useStyles() const [cartItems, setCartItems] = useState<CartItemType[]>([]) const handleAddToCart = (id: number) => { setCartItems((cartItems: CartItemType[]) => { const cartItem = cartItems.find((item) => item.id === id) // すでに同じ商品が入っている場合は数量を増加 if (cartItem) { return cartItems.map((item) => { if (item.id !== id) return item return { ...cartItem, quantity: item.quantity + 1 } }) } // そうでない場合は新たに追加 const newCartItem = products.find(item => item.id === id) return [...cartItems, { ...newCartItem, quantity: 1 }] }) } // 合計金額を算出 const totalCost = cartItems.reduce( (acc: number, item: CartItemType) => acc + (item.price || 0) * item.quantity, 0 ) const stripePublicKey = process.env.REACT_APP_STRIPE_PUBLIC_KEY || "" // Stripe APIの公開鍵 const stripePromise = loadStripe(stripePublicKey) return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg"> <Grid container spacing={4} justify="center" className={classes.container}> {products.map(product => ( <Grid item key={product.id}> <Product title={product.title} description={product.description} price={product.price} image={product.image} handleAddToCart={() => handleAddToCart(product.id)} /> </Grid> ))} </Grid> <Cart cartItems={cartItems} totalCost={totalCost} setCartItems={setCartItems} /> { cartItems.length > 0 && ( <Elements stripe={stripePromise}> <CheckoutForm totalCost={totalCost} /> </Elements> )} </Container> </main> </> ) } export default App header ./src/components/layouts/Header.tsx import React from "react" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import IconButton from "@material-ui/core/IconButton" import Typography from "@material-ui/core/Typography" import InputBase from "@material-ui/core/InputBase" import { createStyles, fade, Theme, makeStyles } from "@material-ui/core/styles" import MenuIcon from "@material-ui/icons/Menu" import SearchIcon from "@material-ui/icons/Search" const useStyles = makeStyles((theme: Theme) => createStyles({ header: { flexGrow: 1 }, menuButton: { marginRight: theme.spacing(2) }, title: { flexGrow: 1, display: "none", [theme.breakpoints.up("sm")]: { display: "block" } }, search: { position: "relative", borderRadius: theme.shape.borderRadius, backgroundColor: fade(theme.palette.common.white, 0.15), "&:hover": { backgroundColor: fade(theme.palette.common.white, 0.25), }, marginLeft: 0, width: "100%", [theme.breakpoints.up("sm")]: { marginLeft: theme.spacing(1), width: "auto" } }, searchIcon: { padding: theme.spacing(0, 2), height: "100%", position: "absolute", pointerEvents: "none", display: "flex", alignItems: "center", justifyContent: "center" }, inputRoot: { color: "inherit" }, inputInput: { padding: theme.spacing(1, 1, 1, 0), paddingLeft: `calc(1em + ${theme.spacing(4)}px)`, transition: theme.transitions.create("width"), width: "100%", [theme.breakpoints.up("sm")]: { width: "12ch", "&:focus": { width: "20ch" } } } }) ) const Header: React.FC = () => { const classes = useStyles() return ( <div className={classes.header}> <AppBar position="static"> <Toolbar> <IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="open drawer" > <MenuIcon /> </IconButton> <Typography className={classes.title} variant="h6" noWrap> Sample Shop </Typography> <div className={classes.search}> <div className={classes.searchIcon}> <SearchIcon /> </div> <InputBase placeholder="Search…" classes={{ root: classes.inputRoot, input: classes.inputInput, }} inputProps={{ "aria-label": "search" }} /> </div> </Toolbar> </AppBar> </div> ) } export default Header product ./src/components/product/Product.tsx import React from "react" import { makeStyles } from "@material-ui/core/styles" import Card from "@material-ui/core/Card" import CardHeader from "@material-ui/core/CardHeader" import CardMedia from "@material-ui/core/CardMedia" import CardContent from "@material-ui/core/CardContent" import Typography from "@material-ui/core/Typography" import ShoppingCartOutlinedIcon from "@material-ui/icons/ShoppingCartOutlined" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" const useStyles = makeStyles(() => ({ card: { minWidth: 300, margin: "1rem", transition: "all 0.3s", "&:hover": { boxShadow: "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)", transform: "translateY(-3px)" } }, cardHeader: { textAlign: "center" }, cardMedia: { height: 0, paddingTop: "56.25%" }, box: { padding: "0 1rem 1rem" }, cartBtn: { textTransform: "none" } })) interface ProductProps { handleAddToCart: React.MouseEventHandler<HTMLButtonElement> | undefined price: number title: string description: string image: string } const Product: React.FC<ProductProps> = ({ handleAddToCart, price, title, description, image }) => { const classes = useStyles() return ( <Card className={classes.card}> <CardHeader title={title} className={classes.cardHeader} /> <CardMedia className={classes.cardMedia} image={image} title={title} /> <CardContent> <Typography variant="body1" color="inherit" component="p" align="center" gutterBottom> {description} </Typography> <Typography variant="body2" color="textSecondary" component="p" align="center"> ¥{price} </Typography> </CardContent> <Box className={classes.box}> <Button variant="outlined" color="primary" startIcon={<ShoppingCartOutlinedIcon />} className={classes.cartBtn} fullWidth onClick={handleAddToCart} > Add to cart </Button> </Box> </Card> ) } export default Product cart ./src/components/cart/Cart.tsx import React from "react" import CartItem from "./CartItem" import { makeStyles } from "@material-ui/core/styles" import { Container } from "@material-ui/core" import Button from "@material-ui/core/Button" import ClearOutlinedIcon from "@material-ui/icons/ClearOutlined" import { CartItem as CartItemType } from "../../interfaces/index" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem", maxWidth: 800, textAlign: "center" }, resetBtn: { textTransform: "none" } })) interface CartProps { cartItems: CartItemType[] totalCost: number setCartItems: Function } const Cart: React.FC<CartProps> = ({ cartItems, totalCost, setCartItems }) => { const classes = useStyles() // カート内の商品をクリア const handleResetCart = () => { setCartItems([]) } return ( <> <Container className={classes.container}> <h2>Your shopping cart</h2> { cartItems.length > 0 ? ( <> {cartItems.map((cartItem: CartItemType) => ( <CartItem key={cartItem.id} id={cartItem.id} title={cartItem.title} cost={(cartItem.price || 0) * cartItem.quantity} quantity={cartItem.quantity} /> ))} <h4>Total cost: ¥{totalCost.toFixed(2)}</h4> <Button type="submit" variant="outlined" color="secondary" startIcon={<ClearOutlinedIcon />} className={classes.resetBtn} onClick={handleResetCart} > Clear cart </Button> </> ) : ( <p>Empty</p> )} </Container> </> ) } export default Cart ./src/components/cart/CartItem.tsx import React from "react" import { makeStyles } from "@material-ui/core/styles" import Grid from "@material-ui/core/Grid" import Divider from "@material-ui/core/Divider" const useStyles = makeStyles(() => ({ container: { flexGrow: 1, marginTop: 0 }, item: { textAlign: "center", display: "table-cell", verticalAlign: "middle" }, divider: { marginTop: "0.5rem" } })) interface CartItemProps { id: number | undefined title: string | undefined quantity: number cost: number | undefined } const CartItem: React.FC<CartItemProps> = ({ title, quantity, cost }) => { const classes = useStyles() return ( <> <Grid container spacing={3} justify="center" className={classes.container}> <Grid item xs={4} className={classes.item}> {title} </Grid> <Grid item xs={4} className={classes.item}> {quantity} </Grid> <Grid item xs={4} className={classes.item}> ¥{cost} </Grid> </Grid> <Divider className={classes.divider} /> </> ) } export default CartItem checkout ./src/components/checkout/CheckoutForm.tsx import React, { useState } from "react" import { CardElement, useStripe, useElements, } from "@stripe/react-stripe-js" import { makeStyles } from "@material-ui/core/styles" import { Container } from "@material-ui/core" import Button from "@material-ui/core/Button" import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp" import Dialog from "@material-ui/core/Dialog" import DialogActions from "@material-ui/core/DialogActions" import DialogContent from "@material-ui/core/DialogContent" import DialogContentText from "@material-ui/core/DialogContentText" import DialogTitle from "@material-ui/core/DialogTitle" import Slide from "@material-ui/core/Slide" import { TransitionProps } from "@material-ui/core/transitions" const Transition = React.forwardRef(function Transition( props: TransitionProps & { children?: React.ReactElement<any, any> }, ref: React.Ref<unknown>, ) { return <Slide direction="up" ref={ref} {...props} /> }) const useStyles = makeStyles(() => ({ container: { margin: "5rem 0 3rem", textAlign: "center" }, agreeBtn: { textTransform: "none" }, submitBtn: { textTransform: "none" } })) interface CompletionDialogProps { open: boolean title: string text: string handleClose: VoidFunction } // 決済処理後に表示するダイアログ(成功時も失敗時も) const CompletionDialog = ({ open, title, text, handleClose }: CompletionDialogProps) => { const classes = useStyles() return ( <div> <Dialog open={open} TransitionComponent={Transition} keepMounted onClose={handleClose} > <DialogTitle> {title} </DialogTitle> <DialogContent> <DialogContentText> {text} </DialogContentText> </DialogContent> <DialogActions> <Button color="primary" onClick={handleClose} className={classes.agreeBtn} > Agree </Button> </DialogActions> </Dialog> </div> ) } interface CheckoutFormProps { totalCost: number } const CheckoutForm: React.FC<CheckoutFormProps> = ({ totalCost}) => { const classes = useStyles() const [status, setStatus] = useState<"default" | "submitting" | "succeeded" | "failed">("default") const [open, setOpen] = useState<boolean>(false) const handleOpen = () => { setOpen(true) } const handleClose = () => { setOpen(false) } const stripe = useStripe() const elements = useElements() // Stripeの決済方法はCharges APIとPayment Intents APIの2種類があるのでどちらかを選択 // Charges: シンプルな一方、SCA対応していないためセキュリティ度はやや低い // Payment Intents: Chargesに比べて支払いまでのプロセスが増える一方、SCA対応しているためセキュリティ度は高い // 参照: https://stripe.com/docs/payments/payment-intents/migration/charges#understanding-the-stripe-payment-apis // 必ずしもどちらが良いかというわけではなく上手く使い分けるのが望ましいらしいが、今後の新機能はPayment Intents APIのみに追加されるとの事 // なので今回はPayment Intents API方式で実装 const handleSubmit = async (e: any) => { e.preventDefault() if (!stripe || !elements) return setStatus("submitting") try { const res = await fetch("/.netlify/functions/paymentIntents", { method: "POST", body: JSON.stringify({ amount: totalCost }), headers: { "Content-Type": "application/json" } }) const data = await res.json() const client_secret = data.client_secret // レスポンス内からclient_secretを取得 const card = elements?.getElement(CardElement) || { "token": ""} // クレジットカード情報を取得 // 決済処理 const result = await stripe?.confirmCardPayment(client_secret, { payment_method: { card: card, billing_details: { name: "Test User" // 他にもaddress(住所)、email(メールアドレス)、phone(電話番号)などが付与可能 } } }) if (result?.paymentIntent?.status === "succeeded") { setStatus("succeeded") } else { throw new Error("Network response was not ok.") } } catch (err) { setStatus("failed") } handleOpen() } return ( <Container className={classes.container}> <form onSubmit={handleSubmit}> <h4>Would you like to complete the purchase?</h4> <CardElement /> <Button type="submit" variant="outlined" disabled={status === "submitting"} // submitting中は再度ボタンを押せないように startIcon={<KeyboardArrowUpIcon />} className={classes.submitBtn} > {status === "submitting" ? "Submitting" : "Submit"} </Button> </form> <CompletionDialog open={open} title={status === "succeeded" ? "Succeeded!" : "Failed"} text={status === "succeeded" ? "Thank you, your payment was successful!" : "Sorry, something went wrong. Please check your credit card information again."} handleClose={handleClose} /> </Container> ) } export default CheckoutForm サーバー 最後にサーバー側の実装に入ります。 lambda関数を作成 ./src/lambda/paymentIntents.js import Stripe from "stripe" const secretKey = process.env.STRIPE_SECRET_KEY const stripe = new Stripe(secretKey, { apiVersion: "2020-08-27" }) exports.handler = async (event, context, callback) => { // POSTメソッド以外は拒否 if (event.httpMethod !== "POST") { return callback(null, { statusCode: 405, body: "Method Not Allowed" }) } const data = JSON.parse(event.body) // 1円に満たない金額だった場合はエラー if (parseInt(data.amount) < 1) { return callback(null, { statusCode: 400, body: JSON.stringify({ message: "Some required fields were not supplied." }) }) } await stripe.paymentIntents.create({ amount: parseInt(data.amount), currency: "jpy", description: "Sample Shop", metadata: { integration_check: "accept_a_payment" } }) .then(({ client_secret }) => { return callback(null, { statusCode: 200, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Content-Type" }, body: JSON.stringify({ client_secret: client_secret // 取引を確認するためのclient_secretを返す }) }) }) .catch((err) => { return callback(null, { statusCode: 400, body: JSON.stringify({ message: `Error: ${err.message}` }) }) }) } 環境変数をセット /webpack.functions.js const Dotenv = require("dotenv-webpack") module.exports = { plugins: [new Dotenv()] } /.env REACT_APP_STRIPE_PUBLIC_KEY=<Stripeの公開可能キー> STRIPE_SECRET_KEY=<Stripeのシークレットキー> プロキシの設定 「/.netlify/functions/」へのリクエストを「http://localhost:9000」へ代替し、CORSを有効にします。 ./setupProxy.js const { createProxyMiddleware } = require("http-proxy-middleware") module.exports = function(app) { app.use( "/.netlify/functions/", createProxyMiddleware({ target: "http://localhost:9000", changeOrigin: true }) ) } ./setupProxy.ts import { createProxyMiddleware } from "http-proxy-middleware" module.exports = function(app: any) { app.use( "/.netlify/functions/", createProxyMiddleware({ target: "http://localhost:9000", changeOrigin: true }) ) } Netfily Functionsの設定 Netlify Functionsに関しては下記の記事が参考になるので読んでみてください。 参照: https://qiita.com/Sr_Bangs/items/7867853f5e71bd4ada56 ./netlify.toml [build] Command = "npm run build" Functions = "lambda" Publish = "build" 動作確認 クライアント、サーバーともに準備ができたのでいよいよ動作確認。 npm-scriptsを編集 「./package.json」内の「scripts」を次のように書き換えます。 ./package.json "scripts": { "start": "run-p start:**", "start:app": "react-scripts start", "start:lambda": "netlify-lambda serve src/lambda --config ./webpack.functions.js", "build": "run-p build:**", "build:app": "react-scripts build", "build:lambda": "netlify-lambda build src/lambda", "test": "react-scripts test", "eject": "react-scripts eject" }, これをやっておくと、今後は $ npm run start を一発叩くだけでクライアント(localhost:3000)とサーバー(localhost:9000)が同時に立ち上がるようになるので便利です。 テスト環境の場合、以下のダミークレジットカード情報が使用可能なので色々試してみてください。 Visa カード番号: 4242424242424242 セキュリティコード: 任意の数字3桁 日付: 将来の日付であれば何でもOK 郵便番号: 任意の数字5桁 Mastercard カード番号: 5555555555554444 セキュリティコード: 任意の数字3桁 日付: 将来の日付であれば何でもOK 郵便番号: 任意の数字5桁 JCB カード番号: 3566002020360505 セキュリティコード: 任意の数字3桁 日付: 将来の日付であれば何でもOK 郵便番号: 任意の数字5桁 無事、「Succeeded!」というダイアログが表示されれば成功です。 今回は簡潔化のためにダイアログを表示するだけに留めていますが、実際の運用においては結果に合わせてページを遷移するなどの工夫を加えてみると良いかもしれません。 念のため、Stripeダッシュボードの「支払い」から確認しておきましょう。 デプロイ https://www.netlify.com Netlifyのアカウントをお持ちでない場合は作成しておいてください。(GitHubアカウントでの作成を推奨。) ログインしてダッシュボードに入ると、「New site from Git」というボタンがあるのでクリック。 するとデプロイのためのリポジトリを選択する画面になるので、該当のリポジトリを選択しましょう。(事前に先ほど作成したコードをGitHubにアップロードしておいてください。) あとはデプロイ時の設定を上記画像のように行い、「Deploy site」をクリックすればOKです。数分程度でビルド〜デプロイまで完了するはず。 あとがき 以上、簡易的なものではありますが、ECサイトもどきを作ってみました。 サーバー有りでStripeを利用しようと思った場合、Netlify Functionsを使えば費用を大幅に抑える事ができそうです。ちょっとしたペラページで運用したい場合などは試してみてはいかがでしょうか。 今回作成したコード: https://github.com/kazama1209/simple-e-commerce
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React-Rails】Railsのコントローラーで深すぎるパラメータの値の取得方法【params】

axiosから値を取得したい! フロントがreactでバックエンドにRailsAPIで、フロントからaxiosのputで値をバックエンド側に値を渡そうとしたとき、paramsのデータ構造がが深くなってしまいました。 これを受け取るのに、苦労したので備忘録的に残しておきます。 axiosで渡す値を設定 export const updateHumanApi = (food) => { return axios.put(URL),{ params:{ human:human, human_name:human.name } }) .then((res)=>{ return res.data }) コントローラー側 axiosで渡された値をlogger.debugで値を確認するとこのようになりました。深すぎる。。。。 paramsの値 Parameters: {"params"=>{"human"=>{"id"=>1, "user_id"=>1, "name"=>"名前1", "point"=>500, "description"=>"説明文です。", "image"=>nil, "count"=>0, "station"=>"0駅", "deleted"=>false, "created_at"=>"2021-05-11T13:55:09.604Z", "updated_at"=>"2021-05-11T13:55:09.604Z"}, "human_name"=>"名前_0"}, "id"=>"1", "human"=>{}} 名前の値を取得しようとするときの構造を分かりやすく抜き出すと、このようになります。 name Parameters: {"params"=>{"human"=>{"name"=>"名前1"}} 上記の値を取得しようとすると、このようになります。 RailsController params[:params][:food][:name] 参考 Railsガイド https://railsguides.jp/active_record_basics.html#update
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ファイルアップロード時のRailsにおけるmultipart指定について

この記事について ユーザーのアバター画像を設定するためにCarrierwaveを使って画像のアップロード機能を実装していたところ、なぜか画像がアップロードされないエラーが発生した。Railsにおけるmultipart指定について理解が曖昧だったのが原因だったこともあり、その時の対応策を備忘録として残しておきます。 環境 Rails6 (6.1.3) Carrierwave(2.2.1) エラー内容 画像ファイルを選択した上でsubmitしても画像が登録されなかった。 パラメータを確認したところ以下のようにavatarのパラメータ値がファイル名になっている。 Parameters: {"authenticity_token"=>"[FILTERED]", "user_profile"=>{ "name"=>”~~~~~~”, "avatar"=> “avatar-img.jpg" }, "commit"=>"更新", "id"=>"1"} 本来は以下のようにActionDispatch::Http::UploadedFileによってファイル名や画像の形式などが 渡されなければならないはず。 Parameters: {"authenticity_token"=>"[FILTERED]", "user_profile"=>{ "name"=>”~~~~~~”, "avatar"=>#<ActionDispatch::Http::UploadedFile:0x00007f66d8e0cae0 @tempfile=# <Tempfile:/tmp/RackMultipart20210420-9-18sadv85o.jpg>, @original_filename=“avatar-img.jpg", @content_type="image/jpeg", @headers="Content-Disposition: form-data; name=\"user_profile[avatar]\"; filename=\"avatar-img.jpg\"\r\nContent-Type: image/jpeg\r\n"> }, "commit"=>"更新", "id"=>"1"} 設定ファイル関連で何かエラーがあるのかと,carrierwave.rbやuploader.rbなどを確認したが問題はなさそう。 ファイル名がそのまま渡されていたのをヒントにmultipartの指定が怪しいと思い:multipart => true をformタグに追加してみると無事にファイルアップロードできた! = form_with model: @user_profile, url: user_profile_path(@user_profile), html: { method: :patch, multipart: true } do |f| multipartとは multipartは複数の種類のデータを一度に扱える形式で、trueを指定することでフォームで文字列だけでなくcsvファイルや画像データなどを扱うことができる。今回はこの指定がされていなかったのでファイルを読み込んでもファイル名が文字列と認識されてしまい、
画像ファイルをアップロードする事が出来ていなかった。 :multipart => trueは必ず指定しないといけない? しかし、Railsで過去にファイルのアップロードを含むフォームを実装した際には:multipart => trueを指定しなくてもうまく機能していた。 何故なのか不思議だったが、その答えはRailsガイドに記載されていた。 Action View Form Helpers 6 Uploading Files どうやら form_withメソッドでモデルを指定したフォーム内にfile_field使用すると、自動的に:multipart => trueが適用されるらしい。 <%= form_with model: @person do |f| %> <%= f.file_field :picture %> <% end %> モデルを指定しない場合は:multipart => trueを指定しないといけない <%= form_with url: "/uploads", multipart: true do |f| %> <%= f.file_field :picture %> <% end %> 今回はform_withメソッドでモデルを指定したフォームを使用していたが、アップロード画像のプレビュー表示をReactを使って実装していたため、file_fieldを使用していなかった。 .field = f.label :avatar = react_component("UserAvatarPreview") そのため、ファイルアップロードのためには:multipart => trueを指定しないといけなかったらしい。 まとめ form_withメソッドでモデルを指定したフォーム内にfile_field使用すると、自動的に:multipart => trueが適用される モデルを指定していなかったりfile_field使用していない場合は:multipart => trueを指定しなければならない
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む