実践
Goでgenko.meのページ送り、保存済み下書きのプレビュー、crypto/hmacによるWebhook署名検証を実装する手順。
導入で書いたClientを使い、実際のサイトで必要になる3つを組みます。
ページ送り
配信APIにページ番号のパラメータはありません。limitとoffsetを送り、返ってきたTotalCountからページ数を計算します。
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ページ目にします。
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を置き換えて開きます。受け取ったキーをそのまま詳細取得のクエリに足すと、保存済みの下書きが返ります。
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テンプレートを設定します。
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()でストリームから直接デコードすると、検証すべき本文が残りません。
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))}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日