Repository: sacloud/webaccel-api-go
Pull request #61: オリジンガードトークン、Let's Encrypt、Brotli圧縮、ログ設定のサポートを追加
Author: login: g1eng
Labels: name: minor
description: tagprのバージョン管理。このラベルのついた機能がマージされたらマイナーバージョンが上がる
Created: 2025-05-01T03:57:36Z | Merged: 2025-05-13T14:05:41Z

# 概要

このPRでは、[APIドキュメント](https://manual.sakura.ad.jp/cloud/webaccel/api.html)に記載のある操作のうち、このSDKで未実装となっていた以下の機能に対するサポートを追加します。

[操作]

* オリジンガードトークンの作成・削除
* オリジンガードトークンの次期トークン作成・トークン更新キャンセル
* Let's Encrypt自動更新証明書の有効化、自動更新無効化
* アクセスログアップロード設定の作成・更新、削除

[パラメタ]

* NormalizeAE (brotliないしはgzipによるAccept-Encoding正規化)

## テストの実行条件に関する備考

* Let's Encrypt設定については、事前に Let's Encryptが無効化されたサイトでテストする必要があります。
* NormalizeAEについては、パラメタの変更の挙動のみ検証しています。brotli配信を有効化したオリジンサーバーでの検証は未実施です。
* アクセスログアップロード設定については、他のテストと同様に環境変数経由でクレデンシャルを注入します。テスト用のバケットを作成した上で、テストランナーから以下の環境変数を参照する必要があります。
  - SAKURASTORAGE_BUCKET_NAME
  - SAKURASTORAGE_ACCESS_KEY
  - SAKURASTORAGE_ACCESS_SECRET

resolve #51 


Linked issue #51: Brotli圧縮の配信サポート
from: https://github.com/sacloud/iaas-api-go/issues/249
Linked issue comments: user: login: yamamoto-febc
created_at: 2023-12-14T05:35:43Z
body: 参考: https://manual.sakura.ad.jp/cloud/webaccel/api.html
author_association: MEMBER

Diff:
diff --git a/api.go b/api.go
index 95945d4..f079714 100644
--- a/api.go
+++ b/api.go
@@ -23,6 +23,12 @@ type API interface {
 	Read(ctx context.Context, id string) (*Site, error)
 	Update(ctx context.Context, id string, param *UpdateSiteRequest) (*Site, error)
 	UpdateStatus(ctx context.Context, id string, param *UpdateSiteStatusRequest) (*Site, error)
+	CreateOriginGuardToken(ctx context.Context, id string) (*OriginGuardTokenResponse, error)
+	DeleteOriginGuardToken(ctx context.Context, id string) error
+	CreateNextOriginGuardToken(ctx context.Context, id string) (*OriginGuardTokenResponse, error)
+	DeleteNextOriginGuardToken(ctx context.Context, id string) error
+	CreateAutoCertUpdate(ctx context.Context, id string) error
+	DeleteAutoCertUpdate(ctx context.Context, id string) error
 	ReadACL(ctx context.Context, id string) (*ACLResult, error)
 	UpsertACL(ctx context.Context, id string, acl string) (*ACLResult, error)
 	DeleteACL(ctx context.Context, id string) error
@@ -34,4 +40,7 @@ type API interface {
 	DeleteCache(ctx context.Context, param *DeleteCacheRequest) ([]*DeleteCacheResult, error)
 	Delete(ctx context.Context, id string) (*Site, error)
 	MonthlyUsage(ctx context.Context, targetYM string) (*MonthlyUsageResults, error)
+	ApplyLogUploadConfig(ctx context.Context, id string, param *LogUploadConfig) (*LogUploadConfig, error)
+	ReadLogUploadConfig(ctx context.Context, id string) (*LogUploadConfig, error)
+	DeleteLogUploadConfig(ctx context.Context, id string) error
 }
diff --git a/constants.go b/constants.go
index 518fb25..ad9d308 100644
--- a/constants.go
+++ b/constants.go
@@ -44,3 +44,9 @@ const (
 	DocIndexDisabled = "0" // 無効
 	DocIndexEnabled  = "1" // 有効
 )
+
+// NormalizeAE
+const (
+	NormalizeAEGz   = "1" // gzipに正規化
+	NormalizeAEBrGz = "3" // brとgzipの組に正規化
+)
diff --git a/log_upload_config.go b/log_upload_config.go
new file mode 100644
index 0000000..99afff8
--- /dev/null
+++ b/log_upload_config.go
@@ -0,0 +1,11 @@
+package webaccel
+
+// LogUploadConfig ログアップロード設定
+type LogUploadConfig struct {
+	Bucket          string `json:","`
+	Endpoint        string `json:"," validate:",equal=https://s3.isk01.sakurastorage.jp"`
+	Region          string `json:"," validate:",equal=jp-north-1"`
+	AccessKeyID     string `json:","`
+	SecretAccessKey string `json:","`
+	Status          string `json:"," validate:"oneof=enabled disabled"`
+}
diff --git a/op.go b/op.go
index 30b988d..4814ac7 100644
--- a/op.go
+++ b/op.go
@@ -305,6 +305,93 @@ func (o *Op) UpdateCertificate(ctx context.Context, id string, param *CreateOrUp
 	return results.Certificate, nil
 }
 
+// CreateAutoCertUpdate Let's Encrypt 自動更新証明書有効化
+func (o *Op) CreateAutoCertUpdate(ctx context.Context, id string) error {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/auto-cert-update", id)
+
+	// build request body
+	type autoCertUpdateRequest struct {
+		Type string
+	}
+	body := &autoCertUpdateRequest{Type: "letsencrypt"}
+
+	// do request
+	_, err := o.Client.Do(ctx, "POST", url, body)
+	return err
+}
+
+// DeleteAutoCertUpdate Let's Encrypt 自動更新証明書無効化
+func (o *Op) DeleteAutoCertUpdate(ctx context.Context, id string) error {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/auto-cert-update", id)
+
+	// build request body
+	var body interface{}
+	_, err := o.Client.Do(ctx, "DELETE", url, body)
+	return err
+}
+
+// CreateOriginGuardToken オリジンガードトークンの発行・更新
+func (o *Op) CreateOriginGuardToken(ctx context.Context, id string) (*OriginGuardTokenResponse, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/origin-guard-token", id)
+
+	var body interface{}
+	// do request
+	data, err := o.Client.Do(ctx, "POST", url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	var results OriginGuardTokenResponse
+	if err := json.Unmarshal(data, &results); err != nil {
+		return nil, err
+	}
+	return &results, nil
+}
+
+// CreateNextOriginGuardToken オリジンガードトークンの次期トークン作成
+func (o *Op) CreateNextOriginGuardToken(ctx context.Context, id string) (*OriginGuardTokenResponse, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/origin-guard-token/next", id)
+
+	var body interface{}
+	// do request
+	data, err := o.Client.Do(ctx, "POST", url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	var results OriginGuardTokenResponse
+	if err := json.Unmarshal(data, &results); err != nil {
+		return nil, err
+	}
+	return &results, nil
+}
+
+// DeleteOriginGuardToken オリジンガードトークンの削除
+func (o *Op) DeleteOriginGuardToken(ctx context.Context, id string) error {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/origin-guard-token", id)
+
+	var body interface{}
+	// do request
+	_, err := o.Client.Do(ctx, "DELETE", url, body)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// DeleteNextOriginGuardToken オリジンガードトークンの次期トークン更新キャンセル
+func (o *Op) DeleteNextOriginGuardToken(ctx context.Context, id string) error {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/origin-guard-token/next", id)
+
+	var body interface{}
+	// do request
+	_, err := o.Client.Do(ctx, "DELETE", url, body)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
 // DeleteCertificate サイトの証明書を削除
 func (o *Op) DeleteCertificate(ctx context.Context, id string) error {
 	url := o.Client.RootURL() + fmt.Sprintf("site/%s/certificate", id)
@@ -401,3 +488,67 @@ func (o *Op) Delete(ctx context.Context, id string) (*Site, error) {
 	}
 	return results.Site, nil
 }
+
+// ApplyLogUploadConfig ログアップロード設定を作成・更新
+func (o *Op) ApplyLogUploadConfig(ctx context.Context, id string, param *LogUploadConfig) (*LogUploadConfig, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	type applyLogUploadConfigRequest struct {
+		SiteLogUploadConfig *LogUploadConfig `json:",omitempty"`
+	}
+	req := applyLogUploadConfigRequest{
+		SiteLogUploadConfig: param,
+	}
+	// do request
+	data, err := o.Client.Do(ctx, "PUT", url, req)
+	if err != nil {
+		return nil, err
+	}
+
+	// build results
+	type applyLogUploadConfigResponse applyLogUploadConfigRequest
+	var result applyLogUploadConfigResponse
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil, err
+	}
+	return result.SiteLogUploadConfig, nil
+}
+
+// ReadLogUploadConfig ログアップロードを取得
+func (o *Op) ReadLogUploadConfig(ctx context.Context, id string) (*LogUploadConfig, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	var body interface{}
+
+	// do request
+	data, err := o.Client.Do(ctx, "GET", url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	// build results
+	type ReadLogUploadConfigResponse struct {
+		SiteLogUploadConfig *LogUploadConfig `json:",omitempty"`
+	}
+
+	var result ReadLogUploadConfigResponse
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil, err
+	}
+	return result.SiteLogUploadConfig, nil
+}
+
+// DeleteLogUploadConfig ログアップロードを削除
+func (o *Op) DeleteLogUploadConfig(ctx context.Context, id string) error {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	var body interface{}
+
+	// do request
+	_, err := o.Client.Do(ctx, "DELETE", url, body)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/op_test.go b/op_test.go
index ea047a5..0d1c369 100644
--- a/op_test.go
+++ b/op_test.go
@@ -20,6 +20,7 @@ import (
 	"os"
 	"strings"
 	"testing"
+	"time"
 
 	client "github.com/sacloud/api-client-go"
 	"github.com/sacloud/packages-go/pointer"
@@ -44,10 +45,10 @@ func testClient() webaccel.API {
 	})
 }
 
-// TestSenario_Op_Create_Enable_Disable_Delete
+// TestScenario_Op_Create_Enable_Disable_Delete
 // サイトの状態により実行結果が変化するメソッドのテストシナリオ
 // 実行順序: Create -> Enable -> Disable -> Delete
-func TestSenario_Op_Create_Enable_Disable_Delete(t *testing.T) {
+func TestScenario_Op_Create_Enable_Disable_Delete(t *testing.T) {
 	checkEnv(t)
 
 	client := testClient()
@@ -60,11 +61,13 @@ func TestSenario_Op_Create_Enable_Disable_Delete(t *testing.T) {
 		OriginProtocol:  webaccel.OriginProtocolsHttps,
 		VarySupport:     webaccel.VarySupportEnabled,
 		DefaultCacheTTL: pointer.NewInt(3600),
+		NormalizeAE:     webaccel.NormalizeAEBrGz,
 	})
 
 	require.NoError(t, err)
 	require.Equal(t, created.Name, name)
 	require.Equal(t, created.VarySupport, webaccel.VarySupportEnabled)
+	require.Equal(t, created.NormalizeAE, webaccel.NormalizeAEBrGz)
 	require.Equal(t, created.DefaultCacheTTL, 3600)
 	require.NotEmpty(t, created.ID)
 
@@ -131,6 +134,7 @@ func TestOp_Update(t *testing.T) {
 		CORSRules:         &[]*webaccel.CORSRule{},
 		OnetimeURLSecrets: &[]string{},
 		DefaultCacheTTL:   pointer.NewInt(0),
+		NormalizeAE:       webaccel.NormalizeAEGz,
 	})
 
 	require.NoError(t, err)
@@ -141,6 +145,74 @@ func TestOp_Update(t *testing.T) {
 	require.Equal(t, updated.DefaultCacheTTL, 0)
 }
 
+func TestOp_OriginGuardToken(t *testing.T) {
+	checkEnv(t, "SAKURACLOUD_WEBACCEL_SITE_ID")
+
+	var (
+		siteId           string
+		token, nextToken *webaccel.OriginGuardTokenResponse
+		err              error
+	)
+	client := testClient()
+
+	t.Run("create origin guard token", func(t *testing.T) {
+		siteId = os.Getenv("SAKURACLOUD_WEBACCEL_SITE_ID")
+		token, err = client.CreateOriginGuardToken(context.Background(), siteId)
+		require.NoError(t, err)
+		require.NotEqual(t, len(token.OriginGuardToken), 0)
+		require.Equal(t, len(token.NextOriginGuardToken), 0)
+	})
+	t.Run("read origin guard token", func(t *testing.T) {
+		res, err := client.Read(context.Background(), siteId)
+		require.NoError(t, err)
+		require.NotEqual(t, len(res.OriginGuardToken), 0)
+		require.Equal(t, res.OriginGuardToken, token.OriginGuardToken)
+	})
+
+	t.Run("create next origin guard token", func(t *testing.T) {
+		nextToken, err = client.CreateNextOriginGuardToken(context.Background(), siteId)
+		require.NoError(t, err)
+		require.NotEqual(t, len(nextToken.OriginGuardToken), 0)
+		require.NotEqual(t, len(nextToken.NextOriginGuardToken), 0)
+		require.Equal(t, nextToken.OriginGuardToken, token.OriginGuardToken)
+	})
+	t.Run("migrate to the next origin guard token", func(t *testing.T) {
+		token, err = client.CreateOriginGuardToken(context.Background(), siteId)
+		require.NoError(t, err)
+		require.NotEqual(t, len(token.OriginGuardToken), 0)
+		require.Equal(t, len(token.NextOriginGuardToken), 0)
+		require.Equal(t, token.OriginGuardToken, nextToken.NextOriginGuardToken)
+	})
+
+	t.Run("delete next origin guard token", func(t *testing.T) {
+		err = client.DeleteNextOriginGuardToken(context.Background(), siteId)
+		require.NoError(t, err)
+	})
+
+	t.Run("delete origin guard token", func(t *testing.T) {
+		err = client.DeleteOriginGuardToken(context.Background(), siteId)
+		require.NoError(t, err)
+	})
+
+}
+
+// NOTE: Let's Encrypt 自動更新が無効化されたサイトを用意しないと失敗する
+func TestOp_AutoCertUpdate(t *testing.T) {
+	checkEnv(t, "SAKURACLOUD_WEBACCEL_SITE_ID")
+	client := testClient()
+	siteId := os.Getenv("SAKURACLOUD_WEBACCEL_SITE_ID")
+
+	t.Run("create automatic certificate update configuration", func(t *testing.T) {
+		err := client.CreateAutoCertUpdate(context.Background(), siteId)
+		require.NoError(t, err)
+		time.Sleep(time.Second)
+	})
+	t.Run("delete automatic certificate update configuration", func(t *testing.T) {
+		err := client.DeleteAutoCertUpdate(context.Background(), siteId)
+		require.NoError(t, err)
+	})
+}
+
 func TestWebAccelOp_ACL(t *testing.T) {
 	checkEnv(t, "SAKURACLOUD_WEBACCEL_SITE_ID")
 
@@ -253,6 +325,7 @@ func TestOp_DeleteCache(t *testing.T) {
 	require.NotEmpty(t, result)
 }
 
+// NOTE: 当月中にトラフィックのないアカウントまたは月初のテストで失敗する
 func TestOp_MonthlyUsage(t *testing.T) {
 	checkEnv(t)
 
@@ -264,3 +337,67 @@ func TestOp_MonthlyUsage(t *testing.T) {
 	require.NotEmpty(t, results.Month)
 	require.NotEmpty(t, results.MonthlyUsages)
 }
+
+func TestOp_LogUploadConfig(t *testing.T) {
+	testutil.PreCheckEnvsFunc(
+		"SAKURACLOUD_WEBACCEL_SITE_ID",
+		"SAKURASTORAGE_BUCKET_NAME",
+		"SAKURASTORAGE_ACCESS_KEY",
+		"SAKURASTORAGE_ACCESS_SECRET",
+	)(t)
+
+	client := testClient()
+
+	siteId := os.Getenv("SAKURACLOUD_WEBACCEL_SITE_ID")
+	region := "jp-north-1"
+	endpoint := "https://s3.isk01.sakurastorage.jp"
+	bucketName := os.Getenv("SAKURASTORAGE_BUCKET_NAME")
+	accessKey := os.Getenv("SAKURASTORAGE_ACCESS_KEY")
+	accessSecret := os.Getenv("SAKURASTORAGE_ACCESS_SECRET")
+	status := "enabled"
+
+	var (
+		applied, read *webaccel.LogUploadConfig
+		err           error
+	)
+
+	require.NotEmpty(t, bucketName)
+	require.NotEmpty(t, accessKey)
+	require.NotEmpty(t, accessSecret)
+
+	t.Run("apply the log upload configuration", func(t *testing.T) {
+		applied, err = client.ApplyLogUploadConfig(context.Background(), siteId, &webaccel.LogUploadConfig{
+			Region:          region,
+			Endpoint:        endpoint,
+			Bucket:          bucketName,
+			AccessKeyID:     accessKey,
+			SecretAccessKey: accessSecret,
+			Status:          status,
+		})
+
+		require.NoError(t, err)
+		require.Equal(t, applied.Region, region)
+		require.Equal(t, applied.Endpoint, endpoint)
+		require.Equal(t, applied.Bucket, bucketName)
+		require.Equal(t, applied.AccessKeyID, accessKey)
+		require.Equal(t, applied.SecretAccessKey, accessSecret)
+		require.Equal(t, applied.Status, status)
+	})
+
+	t.Run("read a log upload configuration", func(t *testing.T) {
+		read, err = client.ReadLogUploadConfig(context.Background(), siteId)
+
+		require.NoError(t, err)
+		require.Equal(t, read.Region, applied.Region)
+		require.Equal(t, read.Endpoint, applied.Endpoint)
+		require.Equal(t, read.Bucket, applied.Bucket)
+		require.NotEmpty(t, read.AccessKeyID)
+		require.NotEmpty(t, read.SecretAccessKey)
+		require.Equal(t, read.Status, applied.Status)
+	})
+
+	t.Run("delete the log upload configuration", func(t *testing.T) {
+		err = client.DeleteLogUploadConfig(context.Background(), siteId)
+		require.NoError(t, err)
+	})
+}
diff --git a/parameter.go b/parameter.go
index 97b1a26..cdf6e3a 100644
--- a/parameter.go
+++ b/parameter.go
@@ -57,6 +57,7 @@ type CreateSiteRequest struct {
 	OriginProtocol  string `json:",omitempty" validate:"omitempty,oneof=http https"`
 	DefaultCacheTTL *int   `json:",omitempty" validate:"omitempty,min=-1,max=604800"` // -1:無効, 0 ～ 604800 の範囲内の数値: デフォルトのキャッシュ期間(秒)
 	VarySupport     string `json:",omitempty" validate:"omitempty,oneof=0 1"`         // 0:無効, 1:有効
+	NormalizeAE     string `json:",omitempty" validate:"omitempty,oneof=1 3"`         // 1:Accept-Encodingをgzipに正規化, 3:bzとgzipの組に正規化
 
 	// 「オリジン種別」が「ウェブサーバ」の場合に設定可能な項目
 	Origin     string `json:",omitempty"`
@@ -80,6 +81,7 @@ type UpdateSiteRequest struct {
 	OriginProtocol  string `json:",omitempty" validate:"omitempty,oneof=http https"`
 	DefaultCacheTTL *int   `json:",omitempty" validate:"omitempty,min=-1,max=604800"` // -1:無効, 0 ～ 604800 の範囲内の数値: デフォルトのキャッシュ期間(秒)
 	VarySupport     string `json:",omitempty" validate:"omitempty,oneof=0 1"`         // 0:無効, 1:有効
+	NormalizeAE     string `json:",omitempty" validate:"omitempty,oneof=1 3"`         // 1:Accept-Encodingをgzipに正規化, 3:bzとgzipの組に正規化
 
 	// CORSRules ルール一覧、設定されている場合単一要素を持つ配列となる
 	CORSRules         *[]*CORSRule `json:",omitempty"`
@@ -106,3 +108,9 @@ type UpdateSiteStatusRequest struct {
 type ACLResult struct {
 	ACL string
 }
+
+// OriginGuardTokenResponse サイトのオリジンガードトークン取得レスポンス
+type OriginGuardTokenResponse struct {
+	OriginGuardToken     string `json:","`
+	NextOriginGuardToken string `json:","`
+}
diff --git a/site.go b/site.go
index 32f8e0c..104d765 100644
--- a/site.go
+++ b/site.go
@@ -28,11 +28,15 @@ type Site struct {
 	RequestProtocol string // 0:http/https, 1:httpsのみ, 2:httpsにリダイレクト
 	DefaultCacheTTL int    `validate:"min=-1,max=604800"` // -1:無効, 0 ～ 604800 の範囲内の数値: デフォルトのキャッシュ期間(秒)
 	VarySupport     string // 0:無効, 1:有効
+	// NOTE: List()だと空、Read()でのみ参照可能
+	NormalizeAE string // 1:Accept-Encodingをgzipに正規化, 3:bzとgzipの組に正規化
 
-	OriginType     string // 0:ウェブサーバ, 1:オブジェクトストレージ
-	Origin         string
-	OriginProtocol string
-	HostHeader     string
+	OriginType           string // 0:ウェブサーバ, 1:オブジェクトストレージ
+	Origin               string
+	OriginProtocol       string
+	HostHeader           string
+	OriginGuardToken     string
+	NextOriginGuardToken string
 
 	// オブジェクトストレージをオリジンにする場合
 	BucketName string


Reviews:
user: login: repeatedly
body: API呼び出し周りのコードは問題なさそうだったので、その他の部分に関してコメントしました
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T08:33:30Z
user: login: g1eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T10:31:46Z
user: login: g1eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T10:33:33Z
user: login: g1eng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T10:36:08Z
user: login: yamamoto-febc
body: PRありがとうございます！良さそうです :+1:
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-12T05:29:36Z

Review comments:
diff_hunk: @@ -401,3 +488,70 @@ func (o *Op) Delete(ctx context.Context, id string) (*Site, error) {
 	}
 	return results.Site, nil
 }
+
+// ApplyLogUploadConfig ログアップロード設定を作成・更新
+func (o *Op) ApplyLogUploadConfig(ctx context.Context, id string, param *LogUploadConfig) (*LogUploadConfig, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	type applyLogUploadConfigRequest struct {
+		SiteLogUploadConfig *LogUploadConfig `json:",omitempty"`
+	}
+	req := applyLogUploadConfigRequest{
+		SiteLogUploadConfig: param,
+	}
+	// do request
+	data, err := o.Client.Do(ctx, "PUT", url, req)
+	if err != nil {
+		return nil, err
+	}
+
+	// build results
+	type applyLogUploadConfigResponse applyLogUploadConfigRequest
+	var result applyLogUploadConfigResponse
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil, err
+	}
+	return result.SiteLogUploadConfig, nil
+}
+
+// ReadLogUploadConfig ログアップロードを取得
+func (o *Op) ReadLogUploadConfig(ctx context.Context, id string) (*LogUploadConfig, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	var body interface{}
+
+	// do request
+	data, err := o.Client.Do(ctx, "GET", url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	// build results
+	type ReadLogUploadConfigResponse struct {
+		SiteLogUploadConfig *LogUploadConfig `json:",omitempty"`
+	}
+
+	var result ReadLogUploadConfigResponse
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil, err
+	}
path: op.go
user: login: repeatedly
body: コードの重複
created_at: 2025-05-08T02:21:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,3 +108,9 @@ type UpdateSiteStatusRequest struct {
 type ACLResult struct {
 	ACL string
 }
+
+// OriginGuardTokenResponse サイトのオリジンガードトークン取得レスポンス
+type OriginGuardTokenResponse struct {
+	OriginGuardToken     string `json:",omitempty"`
+	NextOriginGuardToken string `json:",omitempty"`
path: parameter.go
user: login: repeatedly
body: レスポンスでのデコードで使うので`omitempty`は必要ないように見える。

created_at: 2025-05-08T07:15:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,11 @@
+package webaccel
+
+// LogUploadConfig ログアップロード設定
+type LogUploadConfig struct {
+	Bucket          string `json:",omitempty"`
+	Endpoint        string `json:",omitempty" validate:"omitempty,equal=https://s3.isk01.sakurastorage.jp"`
+	Region          string `json:",omitempty" validate:"omitempty,equal=jp-north-1"`
+	AccessKeyID     string `json:",omitempty"`
+	SecretAccessKey string `json:",omitempty"`
+	Status          string `json:",omitempty" validate:"oneof=enabled disabled"`
path: log_upload_config.go
user: login: repeatedly
body: 現状ログアップロード設定のこれらの項目は省略不可能となっているので、`omitempty`をつけない方が事故は減るかと思います(将来緩和される可能性はあります
created_at: 2025-05-08T08:18:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -401,3 +488,70 @@ func (o *Op) Delete(ctx context.Context, id string) (*Site, error) {
 	}
 	return results.Site, nil
 }
+
+// ApplyLogUploadConfig ログアップロード設定を作成・更新
+func (o *Op) ApplyLogUploadConfig(ctx context.Context, id string, param *LogUploadConfig) (*LogUploadConfig, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	type applyLogUploadConfigRequest struct {
+		SiteLogUploadConfig *LogUploadConfig `json:",omitempty"`
+	}
+	req := applyLogUploadConfigRequest{
+		SiteLogUploadConfig: param,
+	}
+	// do request
+	data, err := o.Client.Do(ctx, "PUT", url, req)
+	if err != nil {
+		return nil, err
+	}
+
+	// build results
+	type applyLogUploadConfigResponse applyLogUploadConfigRequest
+	var result applyLogUploadConfigResponse
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil, err
+	}
+	return result.SiteLogUploadConfig, nil
+}
+
+// ReadLogUploadConfig ログアップロードを取得
+func (o *Op) ReadLogUploadConfig(ctx context.Context, id string) (*LogUploadConfig, error) {
+	url := o.Client.RootURL() + fmt.Sprintf("site/%s/log-upload-config", id)
+
+	var body interface{}
+
+	// do request
+	data, err := o.Client.Do(ctx, "GET", url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	// build results
+	type ReadLogUploadConfigResponse struct {
+		SiteLogUploadConfig *LogUploadConfig `json:",omitempty"`
+	}
+
+	var result ReadLogUploadConfigResponse
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil, err
+	}
path: op.go
user: login: g1eng
body: 除去しました。
64878413f055eff07fa962a7714a3866e22b6b3a
created_at: 2025-05-08T10:31:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,3 +108,9 @@ type UpdateSiteStatusRequest struct {
 type ACLResult struct {
 	ACL string
 }
+
+// OriginGuardTokenResponse サイトのオリジンガードトークン取得レスポンス
+type OriginGuardTokenResponse struct {
+	OriginGuardToken     string `json:",omitempty"`
+	NextOriginGuardToken string `json:",omitempty"`
path: parameter.go
user: login: g1eng
body: omitempty句のみ除去しました。

64878413f055eff07fa962a7714a3866e22b6b3a

created_at: 2025-05-08T10:33:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,11 @@
+package webaccel
+
+// LogUploadConfig ログアップロード設定
+type LogUploadConfig struct {
+	Bucket          string `json:",omitempty"`
+	Endpoint        string `json:",omitempty" validate:"omitempty,equal=https://s3.isk01.sakurastorage.jp"`
+	Region          string `json:",omitempty" validate:"omitempty,equal=jp-north-1"`
+	AccessKeyID     string `json:",omitempty"`
+	SecretAccessKey string `json:",omitempty"`
+	Status          string `json:",omitempty" validate:"oneof=enabled disabled"`
path: log_upload_config.go
user: login: g1eng
body: ご指摘ありがとうございます。
ユーザー側から見ますと、omitempty指定されたフィールドがサーバー側の必須属性だったりすると、予期せぬデバッグコストがかかる心配もありますし、なにより誤解を生みます。ご指摘の通りかと思います。
~~(そういえば公式ドキュメントには必須属性かどうか明記されていませんね...)~~ (きちんと書いてありました。失礼しました。)


修正しました。
64878413f055eff07fa962a7714a3866e22b6b3a
created_at: 2025-05-08T10:36:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: g1eng
created_at: 2025-05-01T08:20:20Z
body: 取り急ぎ必要となった関係で、まとめて実装させて頂きました🙇
機能単位でPR分割した方が良いようでしたら、ご一報ください。

author_association: CONTRIBUTOR
user: login: repeatedly
created_at: 2025-05-02T10:10:26Z
body: 引き続きの機能追加ありがとうございます！
GWを跨ぐので最終的なレビュー・マージに関しては来週となりますが、パッチ内容は確認します 👀 
author_association: CONTRIBUTOR
user: login: g1eng
created_at: 2025-05-02T10:56:17Z
body: ありがとうございます。承知しました！
お手数おかけしますが、よろしくお願いします。
author_association: CONTRIBUTOR
user: login: g1eng
created_at: 2025-05-09T03:42:08Z
body: レビューありがとうございます。
以下3点について小さな変更を加えました。順序前後してしまい恐縮です。

* `op_test.go`: Accept-Encodingパラメタ設定値検証を追加 95ac8fb1692420d83314938e6d3e1da8e4763738
* `log_upload_config.go` 構造体validateタグ内のomitemptyについても一旦除去 95ac8fb1692420d83314938e6d3e1da8e4763738
* Brotli圧縮関連のコメント・定数名のタイポを修正 fd0cbf978e0aaf26e02c39dd39e602cf2dbcc3dc
author_association: CONTRIBUTOR
user: login: repeatedly
created_at: 2025-05-09T13:03:06Z
body: 対応ありがとうございます！

@yamamoto-febc 来週チェックして貰えると助かります。そこで問題なさそうならマージして1.3.0リリースします。
author_association: CONTRIBUTOR
user: login: g1eng
created_at: 2025-05-12T11:13:39Z
body: @yamamoto-febc  @repeatedly ありがとうございます！
運用上問題のない作りにされているとは思うのですが、リリース時のCIで環境変数の欠如のためにテストケースがスキップされているのが気になりました。

このPRでは新たに3つの環境変数に依存するテストを書いています。
 #6 でも触れられていますが、テスト用のパラメタを外部ファイル(ないしは単一のSECRET)から読めるようにした方が、テスト環境のメンテナンス上便利かも知れない、と感じました。 (本件についてはPRを分けます)
author_association: CONTRIBUTOR
user: login: repeatedly
created_at: 2025-05-13T14:09:34Z
body: パッチはマージしました。後々v1.3.0をリリースします。

> テストケースがスキップされているのが気になりました。

そうですね。この辺は課題として認識しており、terraform等使って上手くサイト設定も自動化して、github actions内でもクリーンなテストを走らせたいと思っているところではあります。
author_association: CONTRIBUTOR
user: login: g1eng
created_at: 2025-05-14T11:02:56Z
body: @repeatedly  スムーズなご対応、ありがとうございました。

> terraform等使って上手くサイト設定も自動化して、github actions内でもクリーンなテストを走らせたいと思っているところではあります。

terraformを使うと管理が大分シンプルになりそうですね！
そのうちまたお力添えできればと思っています。
author_association: CONTRIBUTOR