genko.meDocs

実践

Rubyでgenko.meのページ送り、保存済み下書きのプレビュー、OpenSSLのHMACによるWebhook署名検証を実装する手順。

導入で書いたGenko.getを使い、実際のサイトで必要になる3つを組みます。

ページ送り

配信APIにページ番号のパラメータはありません。limitとoffsetを送り、返ってきたtotalCountからページ数を計算します。

lib/genko/page.rb
# 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になり得ます。

ruby
number = [params[:page].to_i, 1].maxpage = Genko.page(number)
app/views/blog/index.html.erb
<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を置き換えて開きます。受け取ったキーをそのまま詳細取得のクエリに足すと、保存済みの下書きが返ります。

app/controllers/blog_controller.rb
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  endend

Genko.getはqueries.compactでnilを落とすので、draftKey: nilはクエリに含まれません。presenceで空文字もnilに寄せているのは、空のdraftKeyが400になるためです。

管理画面のAPI設定(/w/your-workspace/apis/blog/settings)で、プレビューURLテンプレートを設定します。

text
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し直したものでは検証できません。キーの順序や空白が元の本文と一致しないためです。

lib/genko/webhook.rb
# 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  endend

Sinatraでの受信はこうなります。

app.rb
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  endend

Railsのコントローラなら、request.raw_postとrequest.headers["X-Genko-Signature"]を使います。この受信ルートではCSRF保護を除外してください(skip_forgery_protection)。

app/controllers/genko_webhooks_controller.rb
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日

目次