実践
Rubyでgenko.meのページ送り、保存済み下書きのプレビュー、OpenSSLのHMACによるWebhook署名検証を実装する手順。
導入で書いたGenko.getを使い、実際のサイトで必要になる3つを組みます。
ページ送り
配信APIにページ番号のパラメータはありません。limitとoffsetを送り、返ってきたtotalCountからページ数を計算します。
# frozen_string_literal: true
module Genko PER_PAGE = 10
Page = Data.define(:contents, :total_count, :total_pages, :number) do def previous = number > 1 ? number - 1 : nil def next = number < total_pages ? number + 1 : nil end
def self.page(number, per_page: PER_PAGE) list = get("/blog", limit: per_page, offset: (number - 1) * per_page, orders: "-publishedAt")
Page.new( contents: list[:contents], total_count: list[:totalCount], total_pages: [1, (list[:totalCount].to_f / per_page).ceil].max, number: number ) endendページ番号はURLから届くので、offsetの計算に通す前に検証します。to_iは数値でない文字列を0にするため、そのままでは負のoffsetになり得ます。
number = [params[:page].to_i, 1].maxpage = Genko.page(number)<nav aria-label="ページ送り"> <% if page.previous %> <%= link_to "前のページ", blog_index_path(page: page.previous), rel: "prev" %> <% end %> <span><%= page.number %> / <%= page.total_pages %></span> <% if page.next %> <%= link_to "次のページ", blog_index_path(page: page.next), rel: "next" %> <% end %></nav>limitは1〜100の整数です。範囲外を渡すと400になります。offsetが総件数を超えた場合はエラーではなく、:contentsが空配列で返ります。
全件をループで取り切る処理を、ページ表示のたびに走らせないでください。 1ページあたりのリクエストが記事数に比例して増え、1分300リクエストの制限に届きます。サイトマップやフィードの生成など、全件が必要な処理はrakeタスクや定期実行にして結果を保存します。
def self.all_entries(endpoint, **queries) offset = 0 entries = []
loop do list = get("/#{endpoint}", **queries, limit: 100, offset: offset) entries.concat(list[:contents]) offset += 100 break if offset >= list[:totalCount] end
entriesendなお、offsetでページを辿る途中に記事が公開・削除されると一覧の位置がずれ、重複や取りこぼしが起きます。時点を固定したバックアップの用途には使えません。
保存済み下書きのプレビュー
管理画面の「プレビュー」は、設定したURLテンプレートのdraftKeyを置き換えて開きます。受け取ったキーをそのまま詳細取得のクエリに足すと、保存済みの下書きが返ります。
class BlogController < ApplicationController def show draft_key = params[:draftKey].presence
entry = Genko.get("/blog/#{ERB::Util.url_encode(params[:id])}", draftKey: draft_key) @post = Post.from_entry(entry)
return unless draft_key
# プレビューは検索エンジンにもリファラにも渡さない。 response.headers["X-Robots-Tag"] = "noindex, nofollow" response.headers["Referrer-Policy"] = "no-referrer" response.headers["Cache-Control"] = "private, no-store" rescue Genko::ApiError => e raise unless e.status == 404
head :not_found endendGenko.getはqueries.compactでnilを落とすので、draftKey: nilはクエリに含まれません。presenceで空文字もnilに寄せているのは、空のdraftKeyが400になるためです。
管理画面のAPI設定(/w/your-workspace/apis/blog/settings)で、プレビューURLテンプレートを設定します。
https://your-site.example/blog/{id}?draftKey={key}押さえておく点です。
- 対象は単一コンテンツとオブジェクト形式だけです。一覧に
draftKeyを付けても下書き一覧にはなりません。 - キーは1〜64文字です。空文字を送ると
400、形式は正しくても一致する下書きがなければ404になります。 - プレビューの結果をキャッシュしないでください。
Rails.cache.fetchで包んだ取得処理を、プレビューでも使い回さないようにします。 - ページに
noindex, nofollowとno-referrerを設定し、公開URLを組み立てる共有ボタンやcanonicalはプレビュー中に出さないでください。 - 未保存の編集内容は反映されません。管理画面で保存してから開きます。
Webhookで再生成・再検証する
genko.meは、コンテンツが変わったことを小さなJSONでPOSTします。記事本文は送られてこないので、必要なら受信後に配信APIから取り直します。
シークレットを設定したWebhookはx-genko-timestamp(Unix秒)とx-genko-signature(timestamp.rawBodyのHMAC-SHA256、sha256=付き)を送ります。現在時刻との差が300秒を超えたら拒否します。RubyではOpenSSL::HMAC.hexdigestで同じ値を作り、OpenSSL.secure_compareで比べます。
署名はJSONへ変換する前の、生の本文に対して検証します。 Railsではrequest.raw_post、Rackではrequest.body.readがそれにあたります。paramsはパース済みなので、これをto_jsonし直したものでは検証できません。キーの順序や空白が元の本文と一致しないためです。
# frozen_string_literal: true
require "openssl"
module Genko module Webhook # genko.meが送る署名は `sha256=` + `timestamp.rawBody` のHMAC-SHA256(16進)。 def self.valid_signature?(secret, raw_body, timestamp, signature_header) return false unless timestamp&.match?(/\A(0|[1-9][0-9]*)\z/) return false if (Time.now.to_i - timestamp.to_i).abs > 300 return false if signature_header.nil?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")}" # secure_compare はタイミング攻撃を避ける比較。== で比べないこと。 OpenSSL.secure_compare(expected, signature_header) end endendSinatraでの受信はこうなります。
require "json"require "sinatra/base"require_relative "lib/genko/webhook"
class GenkoWebhook < Sinatra::Base post "/genko-webhook" do secret = ENV.fetch("GENKO_WEBHOOK_SECRET") raw_body = request.body.read timestamp = request.env["HTTP_X_GENKO_TIMESTAMP"] signature = request.env["HTTP_X_GENKO_SIGNATURE"]
halt 401, "Invalid signature" unless Genko::Webhook.valid_signature?(secret, raw_body, timestamp, signature)
payload = JSON.parse(raw_body, symbolize_names: true) entry_id = payload.dig(:entry, :id) # テスト送信では entry が nil logger.info("[genko] #{payload[:event]} #{entry_id || '(なし)'}")
# ここでキャッシュの破棄や、再生成ジョブの登録を行う。 content_type :json { received: true }.to_json endendRailsのコントローラなら、request.raw_postとrequest.headers["X-Genko-Signature"]を使います。この受信ルートではCSRF保護を除外してください(skip_forgery_protection)。
class GenkoWebhooksController < ActionController::API def create secret = Rails.application.credentials.genko_webhook_secret valid = Genko::Webhook.valid_signature?( secret, request.raw_post, request.headers["X-Genko-Timestamp"], request.headers["X-Genko-Signature"] ) return head :unauthorized unless valid
payload = JSON.parse(request.raw_post, symbolize_names: true) # 重い処理はジョブへ渡し、受信自体は即座に返す。 GenkoRefreshJob.perform_later(payload[:api], payload.dig(:entry, :id))
render json: { received: true } endend受信側で気をつけること
| 事項 | 内容 |
|---|---|
| タイムアウト | 10秒。自動リトライはありません |
| 重複 | 配信ログからの手動再送で同じイベントが複数回届きます。何度処理しても壊れない作りにします |
| イベント | 手動で「公開」にした保存はentry.publishedではなくentry.created/entry.updatedです |
| 非公開への変更 | entry.updatedでentry.statusがdraftになります。古い公開ページの無効化も必要です |
| テスト送信 | eventはwebhook.test、entryはnullです。entryがない場合を必ず扱ってください |
再生成をリクエストの中で同期的に行うと10秒を超えます。Active Jobなどに渡し、受信は即座に200を返してください。
登録・配信ログ・失敗時の調べ方はWebhookにまとめています。
関連
最終更新: 2026年10月16日