genko.meDocs

実践

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

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

ページ送り

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

genko/page.go
package genko
import (	"context"	"net/url"	"strconv")
// Page は1ページ分の結果と、ページ送りに必要な情報。type Page[T any] struct {	Contents   []T	TotalCount int	TotalPages int	Number     int}
func (p *Page[T]) HasPrevious() bool { return p.Number > 1 }func (p *Page[T]) HasNext() bool     { return p.Number < p.TotalPages }
// GetPage は1始まりのページ番号で一覧を取得する。func GetPage[T any](ctx context.Context, c *Client, endpoint string, number, perPage int) (*Page[T], error) {	if number < 1 {		number = 1	}
	list, err := GetList[T](ctx, c, endpoint, url.Values{		"limit":  {strconv.Itoa(perPage)},		"offset": {strconv.Itoa((number - 1) * perPage)},		"orders": {"-publishedAt"},	})	if err != nil {		return nil, err	}
	totalPages := (list.TotalCount + perPage - 1) / perPage	if totalPages < 1 {		totalPages = 1	}
	return &Page[T]{		Contents:   list.Contents,		TotalCount: list.TotalCount,		TotalPages: totalPages,		Number:     number,	}, nil}

ページ番号はURLから届くので、offsetの計算に通す前に検証します。strconv.Atoiは変換に失敗するとエラーを返すので、その場合は1ページ目にします。

go
number, err := strconv.Atoi(r.URL.Query().Get("page"))if err != nil || number < 1 {    number = 1}
page, err := genko.GetPage[genko.Post](r.Context(), client, "blog", number, 10)

limitは1〜100の整数です。範囲外を渡すと400になります。offsetが総件数を超えた場合はエラーではなく、Contentsが空スライスで返ります。

全件をループで取り切る処理を、ハンドラの中で走らせないでください。 1リクエストあたりの外部呼び出しが記事数に比例して増え、1分300リクエストの制限に届きます。サイトマップやフィードの生成など、全件が必要な処理は起動時か定期実行にして、結果を保持します。

全件が必要なとき(定期実行向け)
func GetAll[T any](ctx context.Context, c *Client, endpoint string, query url.Values) ([]T, error) {	const perPage = 100	var entries []T
	for offset := 0; ; offset += perPage {		page := url.Values{}		for key, values := range query {			page[key] = values		}		page.Set("limit", strconv.Itoa(perPage))		page.Set("offset", strconv.Itoa(offset))
		list, err := GetList[T](ctx, c, endpoint, page)		if err != nil {			return nil, err		}
		entries = append(entries, list.Contents...)		if offset+perPage >= list.TotalCount {			return entries, nil		}	}}

なお、offsetでページを辿る途中に記事が公開・削除されると一覧の位置がずれ、重複や取りこぼしが起きます。時点を固定したバックアップの用途には使えません。

保存済み下書きのプレビュー

管理画面の「プレビュー」は、設定したURLテンプレートのdraftKeyを置き換えて開きます。受け取ったキーをそのまま詳細取得のクエリに足すと、保存済みの下書きが返ります。

handler.go
func postHandler(client *genko.Client) http.HandlerFunc {	return func(w http.ResponseWriter, r *http.Request) {		id := r.PathValue("id")		draftKey := r.URL.Query().Get("draftKey")
		// 空の draftKey は 400 になるので、付いているときだけ渡す。		query := url.Values{}		if draftKey != "" {			query.Set("draftKey", draftKey)		}
		post, err := genko.GetEntry[genko.Post](r.Context(), client, "blog", id, query)
		var apiErr *genko.APIError		if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {			http.NotFound(w, r)			return		}		if err != nil {			log.Printf("genko: %v", err)			http.Error(w, "記事を取得できませんでした", http.StatusInternalServerError)			return		}
		if draftKey != "" {			// プレビューは検索エンジンにもリファラにも渡さない。			w.Header().Set("X-Robots-Tag", "noindex, nofollow")			w.Header().Set("Referrer-Policy", "no-referrer")			w.Header().Set("Cache-Control", "private, no-store")		}
		renderPost(w, post)	}}

r.PathValueはGo 1.22以降のnet/httpのルーティングです。それより前のバージョンや、chi・gorilla/muxを使っている場合は、それぞれのパラメータ取得に置き換えてください。

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

text
https://your-site.example/blog/{id}?draftKey={key}

押さえておく点です。

  • 対象は単一コンテンツとオブジェクト形式だけです。一覧にdraftKeyを付けても下書き一覧にはなりません。
  • キーは1〜64文字です。空文字を送ると400、形式は正しくても一致する下書きがなければ404になります。
  • プレビューの結果をキャッシュしないでください。 公開表示と同じキャッシュに入れると、下書きが公開ページとして配られます。
  • ページに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秒を超えたら拒否します。Goではcrypto/hmacで同じ値を作り、hmac.Equalで比べます。

署名はJSONへ変換する前の、生の本文に対して検証します。 io.ReadAll(r.Body)で読んだバイト列をそのまま使い、json.Unmarshalはその後で行います。json.NewDecoder(r.Body).Decode()でストリームから直接デコードすると、検証すべき本文が残りません。

genko/webhook.go
package genko
import (	"crypto/hmac"	"crypto/sha256"	"encoding/hex"	"strconv"	"time")
// Payload はgenko.meがWebhookで送るJSON。type Payload struct {	Event     string `json:"event"`	Workspace string `json:"workspace"`	API       string `json:"api"`	// テスト送信では null になる。	Entry *struct {		ID     string `json:"id"`		Status string `json:"status"`	} `json:"entry"`	TriggeredAt string `json:"triggeredAt"`}
// ValidSignature は x-genko-signature ヘッダーを検証する。// 署名は `sha256=` + 生の本文のHMAC-SHA256(16進)。func ValidSignature(secret, rawBody []byte, timestamp, signatureHeader string) bool {	seconds, err := strconv.ParseInt(timestamp, 10, 64)	if err != nil || timestamp != strconv.FormatInt(seconds, 10) || time.Now().Unix()-seconds > 300 || seconds-time.Now().Unix() > 300 { return false }	mac := hmac.New(sha256.New, secret)	mac.Write([]byte(timestamp + "."))	mac.Write(rawBody)	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	// hmac.Equal はタイミング攻撃を避ける比較。== で比べないこと。	return hmac.Equal([]byte(expected), []byte(signatureHeader))}
webhook_handler.go
func webhookHandler(secret []byte, refresh chan<- string) http.HandlerFunc {	return func(w http.ResponseWriter, r *http.Request) {		if r.Method != http.MethodPost {			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)			return		}
		// 上限を付けて読む。青天井にしない。		rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))		if err != nil {			http.Error(w, "Cannot read body", http.StatusBadRequest)			return		}
		if !genko.ValidSignature(secret, rawBody, r.Header.Get("X-Genko-Timestamp"), r.Header.Get("X-Genko-Signature")) {			http.Error(w, "Invalid signature", http.StatusUnauthorized)			return		}
		var payload genko.Payload		if err := json.Unmarshal(rawBody, &payload); err != nil {			http.Error(w, "Invalid JSON", http.StatusBadRequest)			return		}
		// 受信は即座に返し、再生成はチャネル越しに別のgoroutineへ渡す。		select {		case refresh <- payload.API:		default: // 詰まっているなら捨てる。次の通知でまた走る。		}
		w.Header().Set("Content-Type", "application/json")		_ = json.NewEncoder(w).Encode(map[string]bool{"received": true})	}}

r.Header.Getはヘッダー名の大文字小文字を区別しません。X-Genko-Signatureと書いてもx-genko-signatureが取れます。

受信側で気をつけること

事項内容
タイムアウト10秒。自動リトライはありません
重複配信ログからの手動再送で同じイベントが複数回届きます。何度処理しても壊れない作りにします
イベント手動で「公開」にした保存はentry.publishedではなくentry.created/entry.updatedです
非公開への変更entry.updatedでEntry.Statusがdraftになります。古い公開ページの無効化も必要です
テスト送信Eventはwebhook.test、Entryはnilです。Entryがnilの場合を必ず扱ってください

再生成をハンドラの中で同期的に行うと10秒を超えます。上の例のようにチャネルやジョブへ渡し、受信は即座に200を返してください。

登録・配信ログ・失敗時の調べ方はWebhookにまとめています。

関連

最終更新: 2026年10月14日

目次