学習日記

学習記録

curlでTranscoder APIのデバッグをした話

初めに

Railsを用いたアプリケーション内でTranscoder APIを用いた機能を実装したかったのですが、デバッグに苦労しました。 そこでcurlコマンドを用いてデバッグした結果、効率的に実装することが出来たので共有したいと考えました。

今回はTranscoder APIでしたが、他にもAPIを用いた実装をする時には使える手法だと思います。

何故苦労したのか

  • Railsのログはレスポンス以外にも色々なものが記録されてるので、読み解くのが面倒
  • 非同期ジョブの結果確認も手間がかかる
  • そもそもレスポンスボディを出力するのに別途コードを書かなきゃいけない

というかそもそもAPI叩いたら即レスポンスで成功したか失敗したか、失敗したなら何でか教えてくれるのが理想ですよね。 僕のケースでは、jobを作成してもjobのステータスが一切確認できず困っていたのですが、直接curlapiを叩いたところ原因が1発で分かりました。

curlは何を解決してくれるのか

  • レスポンスの内容がその場で確認できる
  • エラーが返ってきた場合も、原因がレスポンスボディに記載されている
  • 非同期ジョブのステータス確認も、ジョブIDを渡してGETすればOK(これはちょっと限定的かも)

実際にcurlを用いてapiを叩いてみる

request.jsonという名前のファイルを作り、内容に以下のように記述します

{
  "inputUri": "gs://STORAGE_BUCKET_NAME/STORAGE_INPUT_VIDEO",
  "outputUri": "gs://STORAGE_BUCKET_NAME/STORAGE_OUTPUT_FOLDER/",
  "config": {
    "elementaryStreams": [
      {
        "key": "video-stream",
        "videoStream": {
          "h264": {
            "heightPixels": 720,
            "widthPixels": 1280,
            "bitrateBps": 1000000
          }
        }
      }
    ],
    "muxStreams": [
      {
        "key": "muxed-stream",
        "container": "mp4",
        "elementaryStreams": ["video-stream"]
      }
    ]
  }
}

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

curl -X POST \
     -H "Authorization: Bearer $(gcloud auth print-access-token)" \
     -H "Content-Type: application/json; charset=utf-8" \
     -d @request.json \
     "https://transcoder.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/jobs"

結果としては

{
  "error": {
    "code": 400,
    "message": "config.elementaryStreams[0].videoStream.CodecSettings.frameRate missing",
    "status": "INVALID_ARGUMENT"
  }
}

このように表示され、frameRateの値を渡していないことが原因だと1発で分かります。 ジョブの作成が成功し、そのジョブの状態を確認したいときは

curl -X GET -H "Authorization: Bearer $(gcloud auth print-access-token)" "https://transcoder.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/jobs/JOB_ID"

このようにすることで、ジョブの詳細を確認することが出来ます。

最後に

今回のケースのように、APIのレスポンス内容に問題がありそうなときは、curlを使って直接リクエストを送ると非常に効率的です。

逆に

  • アプリケーション側のパラメータの渡し方を確認したいとき
  • アプリ内でのAPI呼び出しの流れを確認したいとき

等ではこの方法は使えないので、直接アプリケーションを動かしてデバッグしたほうがよさそうです。

またAPIにリクエストを送る際の各種項目に関してはAPIのリファレンスに記述されているはずなので、各種リファレンスを参考にしてください。

おまけ

今回使用したcurlコマンドのオプションを説明します。

オプション 説明
-X HTTPメソッドを指定する
-H リクエストヘッダーを指定する
-d リクエストボディを指定する(@ファイル名でファイルの内容を送れる)

WebSocketの基本

はじめに

時間に余裕ができたのでAIに面白そうな技術を尋ねていたところ、RailsのAction Cableを勧められました。 調べてみると、このAction Cableの基礎にWebSocket通信が使われていることが分かり、まずはこちらを理解することにしました。


WebSocketとは?

通常のHTTP通信はリクエスト→レスポンスの一往復ですが、 WebSocketは常時接続を維持して、双方向にやり取りできる通信方法です。


サンプルコード

サーバー側

const WebSocket = require("ws");

const server = new WebSocket.Server({ port: 3000 });

console.log("WebSocketサーバー起動中...(ws://localhost:3000)");

// クライアント接続時の処理
server.on("connection", (socket) => {
  console.log("クライアントと接続!");

  // クライアントからメッセージ受信時の処理
  socket.on("message", (message) => {
    console.log("受信:" + message);
    socket.send("サーバーから:" + message);
  });
});

クライアント側(HTML + JavaScript

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>WebSocketテスト</title>
</head>
<body>
  <h1>WebSocket通信テスト</h1>
  <input id="text" type="text" placeholder="送信するメッセージ">
  <button onclick="sendMessage()">送信</button>
  <div id="log"></div>

  <script>
    const socket = new WebSocket("ws://localhost:3000");

    // 接続が確立したとき
    socket.onopen = () => {
      log("接続成功!");
    };

    // サーバーからメッセージを受信したとき
    socket.onmessage = (event) => {
      log("受信:" + event.data);
    };

    // 送信ボタン押下時
    function sendMessage() {
      const message = document.getElementById("text").value;
      socket.send(message);
      log("送信:" + message);
    }

    // ログ出力用
    function log(message) {
      const logDiv = document.getElementById("log");
      const p = document.createElement("p");
      p.textContent = message;
      logDiv.appendChild(p);
    }
  </script>
</body>
</html>

処理の流れ

サーバー側の流れ

処理 内容
server.on("connection", callback) クライアントが接続してきたときの処理
socket 接続してきたクライアント1台ごとの通信口
socket.on("message", callback) クライアントからメッセージ受信時の処理
socket.send() そのクライアントにメッセージを返す

クライアント側の流れ

処理 内容
new WebSocket(url) サーバーへの接続を開始
socket.onopen 接続が確立したとき
socket.onmessage サーバーからメッセージを受信したとき
socket.send() サーバーへメッセージを送信

実行イメージ

メッセージを入力して「送信」ボタンを押す

サーバーが受信し、同じ内容を送り返す

画面に「受信:サーバーから:〜」と表示


おまけ

サンプルコードでは接続してきたクライアントに対してオウム返しでメッセージを返すだけでした。 例えば、接続してきたsocketを配列として保持しておくことで、接続してきたクライアント同士でメッセージを送受信することが出来ます。

const WebSocket = require("ws");
const server = new WebSocket.Server({ port: 3000 });
const clients = [];

server.on("connection", (socket) => {
  clients.push(socket);

  socket.on("message", (message) => {
    clients.forEach((client) => {
      client.send(message);
    });
  });
});

markdown-itを利用して、HTMLタグと属性の許可フィルターを作成した話

主題

Markdownを利用している時、Markdown記法の他にユーザーが自由にHTMLタグを記述できるようにしたいことがあります。 例えばユーザーの質がある程度保証されていて、ある程度ならば自由にタグを使ってレイアウトの表現、自由を確保したい場合等です。

ただし、すべてのタグ・属性を無制限に許可してしまうと、以下のような問題も発生します。

<style>タグで意図しないCSSを仕込まれる

onloadなどのイベント属性でXSS攻撃のリスクが生まれる

そこで今回は、markdowin-itを利用してMarkdown内のHTMLは許可するが、特定のタグと属性だけ禁止する方法を紹介します。

既存ライブラリ

markdown-itを利用したMarkdownのHTML制御には、markdown-it-sanitizerなどのサニタイザー系プラグインが存在します。ただ、これを利用しようとしたところ、以下の問題に直面しました。

  • ほぼすべてのHTMLタグを禁止してしまう

  • 許可するタグ・属性を柔軟に設定する方法がない

  • 既存データにHTMLが含まれる場合、過去記事の表示崩れが発生する

例えば以下のようなHTMLが含まれた記述があった場合、表示が崩れてしまいます

<details>
<summary>ネタバレ防止</summary>
これはネタバレです
</details>

markdown-it-sanitizerを利用した場合、これらのタグは無効化されそのまま出力されてしまいます。

そのため、既存ライブラリの利用は諦めて、自前の処理を作成することにしました。

方針

  • Markdown内のHTMLはそのまま許可
  • ただし、<style>タグとonload属性を検出し、エスケープ(無害化)する
  • 今後禁止したいタグと属性が増えることを見越して、柔軟に対応できる実装にする

そこで、markdown-itのプラグイン機構を活用し、パース後のTokenに対して処理を行う形をとりました。

実装コード

以下のコードをプラグインとして用意し、markdown-itに組み込んでいます。

export default (md) => {
  const escapeTags = ['style']
  const escapeAttributes = ['onload']

  md.core.ruler.push('sanitize', function (state) {
    for (const token of state.tokens) {
      if (['html_block', 'html_inline'].includes(token.type)) {
        token.content = escapeHtmlContent(
          token.content,
          escapeTags,
          escapeAttributes
        )
      } else if (token.type === 'inline' && token.children) {
        for (const child of token.children) {
          child.content = escapeHtmlContent(
            child.content,
            escapeTags,
            escapeAttributes
          )
        }
      }
    }
  })
}

function escapeHtmlContent(html, escapeTags = [], escapeAttributes = []) {
  const parser = new DOMParser()
  const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html')

  doc.querySelectorAll('*').forEach((el) => {
    if (shouldEscape(el, escapeTags, escapeAttributes)) {
      el.replaceWith(doc.createTextNode(el.outerHTML))
    }
  })

  return doc.body.innerHTML
}

function shouldEscape(el, escapeTags, escapeAttributes) {
  const tag = el.tagName.toLowerCase()
  const isDangerousTag = escapeTags.includes(tag)

  const hasDangerousAttribute = [...el.attributes].some((attr) => {
    const attrName = attr.name.toLowerCase()
    return escapeAttributes.includes(attrName)
  })

  return isDangerousTag || hasDangerousAttribute
}

処理の流れ

  1. Markdownのパース後、Tokenを取得

  2. html_block と html_inline のコンテンツに対して、HTMLの構造を解析

  3. escapeTagsとescapeAttributesに含まれているタグ、属性を持つ要素を検出

  4. それらの要素はTextNodeに置き換えて、文字列として表示(実行・反映させない)

動作例

例えば、以下のMarkdownがあった場合

<div onload="alert('xss')">Hello</div>
<style>body { background: red; }</style>

以下のように変換されます

&lt;div onload="alert('xss')"&gt;Hello&lt;/div&gt;
&lt;style&gt;body { background: red; }&lt;/style&gt;

まとめ

今回は必要最低限の制御をカスタム実装することで、

  • 既存データとの互換性維持

  • セキュリティの担保

  • マークアップの柔軟性確保

が両立できました。

もし似たような課題があればぜひ試してみてください。

おまけ

もし禁止するタグ、属性をカスタマイズしたかったら

const escapeTags = ['style', 'script']
const escapeAttributes = ['onload', 'onclick']

addEventListenerでハマった話

主題

addEventListenerを使って1回だけイベントを実行したいが、イベントリスナーが重複して登録されてしまう問題について解説します。

これはチームで開発を行う中で実際に遭遇した問題です。

実際のコードは複雑すぎて、解説するのに相応しくないので同じ問題を再現できる簡易なコードを用意し、何故その問題が起きるのかとその解決策について解説していこうと思います

サンプルコード

const textarea = document.getElementById('myTextarea');
const attachButton = document.getElementById('attach');

attachButton.addEventListener('click', () => {
  textarea.addEventListener('paste', (event) => {
    console.log('ペーストイベント発生');
  });
});

発生する問題

ボタンを複数回押すと、textareaにペーストイベントリスナーが重複して登録され、コンソールに「ペーストイベント発生」がボタンを押した回数分だけ表示されるという問題が発生します。

onceを渡す

attachButton.addEventListener('click', () => {
  textarea.addEventListener('paste', (event) => {
    console.log('ペーストイベント発生');
  }, { once: true });
});

最初に考えた解決方法は、onceオプションを使うことでした。 しかしこれは上手くいきませんでした。 onceを使うことで、イベントが発生したらリスナーが自動的に削除されますが、問題はリスナーが重複して追加されることを防げない点です。onceはあくまで「イベント発生後に削除」するだけなので、複数回addEventListenerが呼ばれること自体は止められません。

解決した方法

attachButton.addEventListener('click', () => {
  if (textarea.dataset.data-listener-attached) return;
  textarea.dataset.data-listener-attached = 'true';

  textarea.addEventListener('paste', (event) => {
    console.log('ペーストイベント発生');
  });
});

この方法では、textarea制御用の属性(例: data-listener-attached)を追加して、2回目以降のボタンのクリックを無視します。 属性フラグを使うことで、イベントリスナーが重複して追加されることを防ぎます。

別解

function handlePaste(event) {
  console.log('ペーストイベント発生')
}

attachButton.addEventListener('click', () => {
  textarea.removeEventListener('paste', handlePaste);
  textarea.addEventListener('paste', handlePaste);
})

ボタンをクリックするたびに、既存のリスナーを削除してから新しいリスナーを追加します。 removeEventListenerを使うことで、リスナーが重複しないように管理できます。

この方法では余計な属性を付けなくていいというのが強いメリットとしてありますが イベントとして関数を直接記述出来なくなるのがデメリットかと思います。

結論

  • onceオプションは「イベント後の削除」には有効ですが、リスナーが重複して追加されることを防げません
  • リスナーが重複して登録されることを防ぐには、制御用属性での管理かremoveEventListenerを毎回呼ぶことで解決できます。

感謝

当バグを修正するにあたって原因調査の大半を担ってくれたチームメンバーのmousuさん、有難うございました!

ActionTextを利用してリッチテキストを扱う

初めに

RailsにおいてActionTextを使ってリッチテキストを扱ってみたいと思います。

既存のブログアプリに対して、ブログの内容がリッチテキストを扱えるように変更しますので、随時ご自身の環境に読み替えてください。

既存のカラムがあるので、既存のカラムを削除するところから始めます。

実装

既存のArticleモデルからcontentカラムを削除します

rails generate migration RemoveContentFromArticle content:text 
dn:migrate

これらを実行してまずはカラムを整理します。

次にActionTextを用意します

bin/rails action_text:install
bundle install
bin/rails db:migrate

ActionTextが用意できたらいよいよモデルがリッチテキストを扱えるように変更します

class Article < ApplicationRecord
  has_rich_text :content
end

残りは投稿に利用するフォームをリッチテキストを簡単に利用できるフォームに変えてあげるだけです

        <%= form_with model: @article, local: true, class: "form" do |form| %>
          <div class="mb-3">
            <%= form.label :title, class: "form-label" %>
            <%= form.text_field :title, class: "form-control" %>
          </div>

          <div class="mb-3">
            <%= form.label :content, class: "form-label" %>
            <%= form.rich_text_area :content, class: "form-control", rows: 5 %>
          </div>

          <div class="d-grid">
            <%= form.submit "Create Article", class: "btn btn-primary" %>
          </div>
        <% end %>

以上でリッチテキストを使用することが出来るようになりました

最後に

とても簡単にリッチテキストを利用出来るようになりました。

保存されたリッチテキストはStringクラスのオブジェクトではなくなるので、文字列を加工したい場合には注意が必要です。

Railsでモーダルを作ってみる

初めに

Turbo Framesとbootstrapを使ってモーダルを実装してみようと思います

gyazo.com

このような、記事の一覧がありクリックすればその詳細が表示されるシンプルなアプリがあります。

これをクリックした際にモーダルとして表示されるようにしてみます。

最終的にはこの画像のようになる予定です。

実装

最初に application.html.erbにこう記述します

<%= turbo_frame_tag 'modal' %>

これはmodalという名前の更新予定地を作ったと考えてください、まだこの状態ではブラウザから表示しても何も表示されません

次にモーダルとして表示するコンテンツを決めます。

モーダルとして表示するviewを以下のようにラップします。

<%= turbo_frame_tag 'modal' do %>
 // content
<% end %>

さらにbootstrapの公式リファレンスからmodalのサンプルコードを一部拝借して

<%= turbo_frame_tag 'modal' do %>
  <div class="modal fade" tabindex="-1" data-controller="modal">
    <div class="modal-dialog modal-lg">
      <div class="modal-content">
        <div class="modal-header" style="padding: 0.5rem;">
          <h1 class="modal-title"><%= default_format(@article.title) %></h1>
          <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <p class="card-text"><%= default_format(@article.content) %></p>
          <%= link_to 'Edit', edit_article_path(@article), class: "btn btn-primary me-2", data: {turbo_frame: '_top'} %>
          <%= link_to "Delete", article_path(@article), class: "btn btn-danger", data: { turbo_method: :delete, turbo_confirm: 'Are you sure?', turbo_frame: '_top' } %>
        </div>
      </div>
    </div>
  </div>
<% end %>

このようにmodal-bodyの中にモーダルとして表示する部分を入れます link_toメソッドで使用しているturbo_frame: '_top'は見慣れない記述だと思いますが、これは画面遷移をするときにモーダルから抜けて、その手前にある画面を遷移することを明示しています。

そしてこのモーダルを呼び出す部分を変更します

<%= link_to 'Show more', article, class: "btn btn-primary", data: {turbo_frame: 'modal' } %>

重要な部分はdata: {turbo_frame: 'modal' }の部分でmodalという名前のついたturbo_frame_tagを更新するということを明示しています。

これでviewで手を加える箇所は終了ですが、まだモーダルを呼び出しても何も表示されないはずです。

コンソールでrails g stimulus modalを実行して、新たなコントローラーを作成し、中身を書き換えます

import { Controller } from "@hotwired/stimulus"
import { Modal } from "bootstrap"

// Connects to data-controller="modal"
export default class extends Controller {
  connect() {
    this.modal = new Modal(this.element)
    this.modal.show()
  }
}

これでモーダルが動くようになります。

補足

実装部分で最後に作ったコントローラーがどんな仕事をしているか簡単に説明しておきます

コメントアウトされている部分にもあるように、data-controller="model"のようにデータ属性を設定したdom要素とこのコントローラーは接続されます。

connect()メソッドはDOM要素に接続された際に自動で呼び出されます。

その後の2行でbootstrapのModalクラスオブジェクトを接続したDOM要素に渡し、それを呼び出しています。

これらの処理がリンクをクリックした時に行われるので、無事モーダルが表示されるわけです。

最後に

思っていたよりは簡単にモーダルを実装することが出来ました。

stimulusも今回初めて知ったのですが、どうしても直接コードを書いていくと煩雑になりがちなものを操作したいDOM要素毎にコントローラーを分けて記述できるのは便利そうだなーと感じました。

lambdaとProc.new

始めに

コードを書いてる最中にProcオブジェクトを作成しようと思った時に、ProcオブジェクトはlambdaProc.newどちらでも作成できることに気が付きました。

作られた方法によりreturn時の挙動と、引数の扱いという2点の違いがあります。

調べた結果、私はlambdaを基本的に使うべきだという結論になりましたが、実際どのように違うかを説明します。

引数の扱い

add_lambda = lambda {|x, y| x + y }
puts add_lambda.call(1, 2) #=> 3
puts add_lambda.call(1, 2, 3) #=> wrong number of arguments (given 3, expected 2) (ArgumentError)
purs add_lambda.call(1) #=> wrong number of arguments (given 1, expected 2) (ArgumentError)

このようにlambdaで作成したProcオブジェクトは引数の数が厳密であることを期待します。 一方でProc.newを利用すると

add_proc = Proc.new { |x, y| x + y }
puts add_proc.call(1, 2) #=> 3
puts add_proc.call(1, 2, 3) #=> 3(3は無視される)
puts add_proc.call(1) # => `+': nil can't be coerced into Integer (TypeError)

このように超過した値は無視され、不足があった値にはnilが代入されます。

returnの挙動

まずはlambdaを用いた場合です

def lambda_test
  test_lambda = lambda { |x, y| return x + y}
  puts "before lambda"
  result = test_lambda.call(1, 2)
  puts "after lambda"
  return result
end

puts lambda_test

# => before lambda
# => after lambda
# => 3

これは恐らく皆さんの想像通りではないでしょうか? 一方でProc.newを用いた場合はこのようになります

def proc_test
  test_proc = Proc.new { |x, y| return x + y}
  puts "before proc"
  result = test_proc.call(1, 2)
  puts "after proc"
  return result
end

puts proc_test

# => before proc
# => 3

after procが出力されずに途中で終了してしまいました。

lambdaで作られたオブジェクトは、return時にブロックから抜け出したのに対して、Proc.newで作られたオブジェクトは、returnする時にオブジェクトを定義したスコープそのものから抜けてしまいます。

最後に

最初は等価だと思っていましたが、詳しくみてみると差異があることが分かり面白かったですね。 私はlambdaで作られたオブジェクトのほうが、直感的で厳密であると感じるのでこちらを推奨していきたいですが、皆さんはどうでしょうか?