ShopifyのGraphQL(Go)でShopifyに画像をアップロードする手順(と過程でHTTPプロトコルについて学んだ話)
はじめに
今回は Shopify の GraphQL Admin API をつかって Shopify に画像をアップロードする手順を共有していこうと思います。
意外と参考になる記事が少なかったのでぜひ参考にしてください!
また今回の実装で HTTP プロトコルについて学ぶことも多かったのでそのあたりもアウトプットしていきたいと思います。
記事を読んでわかること
- ShopifyGraphQLClient(Go)を使って Shopify に画像をアップロードする手順
- HTTP プロトコルの基本的な部分について
- ファイルアップロードの仕様について
事前情報
- go: v1.22.4
- Shopify GraphQL Admin API version: 2024-10
- Shopify GraphQL Client
- API key のスコープに次の権限を付与しておく必要があります
- write_files / read_files / write_themes / read_themes
- フロントエンドは実装しないためアップロードの実行は次のコマンドで実行します
$ go run main.go -shopify-handle {ShopifyHandle名} -secret {APIキー}
実装のゴール確認
今回のゴールは Shopify の管理画面上の コンテンツ > ファイル
にローカル環境からアップロードした画像ファイルが作成されることです。
ファイル作成することで商品画像やその他のリソースで画像を使いたいときに管理画面上から選択できるようになります。
Shopify に画像をアップロードする手順
アップロードの手順は基本的に公式ドキュメント [1] に書かれていますが少し物足りません。
Shopify アプリから画像を Shopify にアップロードする で書かれている手順が今回やりたいことなので今回はこちらを参考にさせていただきました 🙇♂️
もし実装する機会があればぜひ参考にしてみてください!
ステップ 1 Shopify に画像をアップロードするための準備をする
まずは Shopify に画像をアップロードする準備として Shopify GraphQL Admin API の stagedUploadsCreate を使ってアップロード用の URL とパラメーターを取得します。
このステップでアップロード先となる storage の URL と storage へリクエストするために必要な情報を取得します。
公式ドキュメント [2] も参考になると思います。
main.go
処理を実行するメイン関数です。
今回はローカルに準備した画像をアップロードします。
package main
import (
"bytes"
"context"
"flag"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
shopify "github.com/r0busta/go-shopify-graphql/v9"
graphqlclient "github.com/r0busta/go-shopify-graphql/v9/graphql"
)
func main() {
// コマンドのフラグから必要な情報を取得
shopifyHandle := flag.String("shopify-handle", "", "(required)shopify handle")
shopifySecret := flag.String("secret", "", "(required)shopify secret")
flag.Parse()
// クライアント生成
client := NewGraphQLAPIClient(*shopifyHandle, *shopifySecret)
// ローカルに準備したファイルパス
imageURLs := []string{"../../shopify/input/images/test1.png", "../../shopify/input/images/test2.png"}
var input []StagedUploadInput
for _, url := range imageURLs {
s := StagedUploadInput{
Filename: filepath.Base(url),
MimeType: mime.TypeByExtension(filepath.Ext(url)),
HttpMethod: http.MethodPost,
Resource: "FILE",
}
input = append(input, s)
}
// ステップ1: stagedUploadsCreateを使ってアップロード用のURLとパラメーターの取得
res, err := client.StagedUploadsCreate(input)
if err != nil {
log.Fatalf("failed to create staged upload: %v", err)
}
// ステップ2に続く
}
// GraphQLクライアント
type GraphQLAPIClient struct {
client *shopify.Client
}
type ShopifyRegister struct {
Graphql *GraphQLAPIClient
}
func NewGraphQLAPIClient(shopifyHandle, authAccessToken string) *GraphQLAPIClient {
gqlClient := graphqlclient.NewClient(shopifyHandle, graphqlclient.WithToken(authAccessToken), graphqlclient.WithVersion("2024-10"))
client := shopify.NewClient(shopify.WithGraphQLClient(gqlClient))
return &GraphQLAPIClient{
client: client,
}
}
type Parameter struct {
Name string `json:"name"`
Value string `json:"value"`
FilePath string `json:"filePath"`
}
type StagedTarget struct {
URL string `json:"url"`
ResourceURL string `json:"resourceUrl"`
Parameters []Parameter `json:"parameters"`
}
type StagedUploadCreate struct {
StagedTargets []StagedTarget `json:"stagedTargets"`
}
type ResponseStagedUploadCreate struct {
StagedUploadsCreate StagedUploadCreate `json:"stagedUploadsCreate"`
}
type StagedUploadInput struct {
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
HttpMethod string `json:"httpMethod"`
Resource string `json:"resource"`
}
func (g *GraphQLAPIClient) StagedUploadsCreate(input []StagedUploadInput) (*ResponseStagedUploadCreate, error) {
query := `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
}
}`
variables := map[string]interface{}{
"input": input,
}
res := &ResponseStagedUploadCreate{}
if err := g.client.GraphQLClient().MutateString(context.Background(), query, variables, res); err != nil {
return nil, err
}
return res, nil
}
レスポンスを確認する
処理を実行した実際のレスポンスを確認してみます。
url
が画像をアップロードできる URL です。
resourceUrl
は画像アップロードした後のファイル URL です。resourceUrl
はステップの最後でファイル作成をする時に使います。
その他の情報は Shopify の storage へのリクエストに必要な情報が含まれていそうなことが確認できます。
※レスポンスの中身を確認するに Shopify は Google Cloud Storage を使っていて、署名付き URLで画像アップロードできるようになっているのかなと?と思ったのですがソースが見つけれなかったので完全に自分の推測になります。わかる方いればぜひコメントで教えて欲しいです 🙏
{
"data": {
"stagedUploadsCreate": {
"stagedTargets": [
// 複数アップロードの場合は画像ごとにオブジェクトが増える
{
"url": "https://shopify-staged-uploads.storage.googleapis.com/",
"resourceUrl": "https://shopify-staged-uploads.storage.googleapis.com/tmp/xxxxxx/files/xxxxxx/test1.png",
"parameters": [
{
"name": "Content-Type",
"value": "image/png"
},
{
"name": "success_action_status",
"value": "201"
},
{
"name": "acl",
"value": "private"
},
{
"name": "key",
"value": "tmp/xxxxxx/files/xxxxxx/test1.png"
},
{
"name": "x-goog-date",
"value": "20250111T025902Z"
},
{
"name": "x-goog-credential",
"value": "merchant-assets@shopify-tiers.iam.gserviceaccount.com/20250111/auto/storage/goog4_request"
},
{
"name": "x-goog-algorithm",
"value": "GOOG4-RSA-SHA256"
},
{
"name": "x-goog-signature",
"value": "xxxxxx"
},
{
"name": "policy",
"value": "xxxxxx"
}
]
}
]
}
}
}
ステップ 2 Shopify に画像をアップロードする
次に先ほどレスポンスに含まれている値を使って Shopify に画像をアップロードできるようにします。
公式ドキュメント [3] も参考にしてみてください。
本実装では Content-Type を multipart/form-data でリクエストを行うため multipart パッケージ を使っています(ファイルをアップロードするため)。
※multipart/form-data については後ほど解説します
main.go
// ステップ1の続き
// ステップ2: 画像アップロード
httpClient := &http.Client{}
var files []File
for i, stagedTarget := range res.StagedUploadsCreate.StagedTargets {
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
for _, parameter := range stagedTarget.Parameters {
if err := writer.WriteField(parameter.Name, parameter.Value); err != nil {
log.Fatalf("failed to writer.WriteField:%v", err)
}
}
file, err := os.Open(imageURLs[i])
if err != nil {
log.Fatalf("failed to open file: %v", err)
}
defer file.Close()
wp, err := writer.CreateFormFile("file", filepath.Base(imageURLs[i]))
if err != nil {
log.Fatalf("failed to writer.CreateFormFile:%v", err)
}
_, err = io.Copy(wp, file)
if err != nil {
log.Fatalf("failed to io.Copy: %v", err)
}
writer.Close()
req, err := http.NewRequest(http.MethodPost, stagedTarget.URL, &requestBody)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := httpClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// 次のステップ(画像ファイル作成)で必要なので作成しておく
files = append(files, File{
ContentType: "IMAGE",
OriginalSource: stagedTarget.ResourceURL,
})
}
// ステップ3に続く
ここの処理で注意したい点は writer.WriteField()
と writer.CreateFormFile()
の順番です。
自分は最初 writer.CreateFormFile()
でファイルのパートを定義してから writer.WriteField()
でそのほかのパートを定義していました。
しかしこれだと Invalid argument. Cannot create buckets using a POST.
というエラーが発生します。なので最後にファイルのパートを定義するようにしています。
※順序についての仕様は RFC セクション 5.2 が近しい感じもしますが、特に明記はされていないようでした。
ステップ 3 Shopify 上に画像ファイルを作成する
最後に Shopify へアップロードした画像を使ってファイルを作成して管理画面上から画像を選択できるようにします。
Shopify Admin API の fileCreate を使って画像ファイルを作成します。
main.go
// 2の続き
// ステップ3: Shopifyに画像ファイル作成
_, err = client.FileCreate(files)
if err != nil {
log.Fatal(err)
}
}
type File struct {
ContentType string `json:"contentType"`
OriginalSource string `json:"originalSource"`
}
type ResponseFile struct {
Id string `json:"id"`
FileStatus string `json:"fileStatus"`
Alt string `json:"alt"`
CreatedAt string `json:"createdAt"`
}
type ResponseFileCreate struct {
FileCreate struct {
Files []ResponseFile `json:"files"`
} `json:"fileCreate"`
}
func (g *GraphQLAPIClient) FileCreate(files []File) (*ResponseFileCreate, error) {
query := `mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
fileStatus
createdAt
... on MediaImage {
id
}
fileErrors {
code
message
}
}
}
}`
variables := map[string]interface{}{
"files": files,
}
res := &ResponseFileCreate{}
if err := g.client.GraphQLClient().MutateString(context.Background(), query, variables, res); err != nil {
return nil, err
}
return res, nil
}
管理画面上で確認する
ここまでコードを実装できればあとはコマンドを実行するだけです!
$ go run main.go -shopify-handle {ShopifyHandle名} -secret {APIキー}
を実施して Shopify 管理画面の コンテンツ > ファイル
にアップロードした画像ファイルが作成されていればゴールを達成したことになります 🎉
今回実装したコード
main.go
package main
import (
"bytes"
"context"
"flag"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
shopify "github.com/r0busta/go-shopify-graphql/v9"
graphqlclient "github.com/r0busta/go-shopify-graphql/v9/graphql"
)
func main() {
// コマンドのフラグから必要な情報を取得
shopifyHandle := flag.String("shopify-handle", "", "(required)shopify handle")
shopifySecret := flag.String("secret", "", "(required)shopify secret")
flag.Parse()
// クライアント生成
client := NewGraphQLAPIClient(*shopifyHandle, *shopifySecret)
// ローカルに準備したファイルパス
imageURLs := []string{"../../shopify/input/images/test1.png", "../../shopify/input/images/test2.png"}
var input []StagedUploadInput
for _, url := range imageURLs {
s := StagedUploadInput{
Filename: filepath.Base(url),
MimeType: mime.TypeByExtension(filepath.Ext(url)),
HttpMethod: http.MethodPost,
Resource: "FILE",
}
input = append(input, s)
}
// ステップ1: stagedUploadsCreateを使ってアップロード用のURLとパラメーターの取得
res, err := client.StagedUploadsCreate(input)
if err != nil {
log.Fatalf("failed to create staged upload: %v", err)
}
// ステップ2: 画像アップロード
httpClient := &http.Client{}
var files []File
for i, stagedTarget := range res.StagedUploadsCreate.StagedTargets {
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
for _, parameter := range stagedTarget.Parameters {
if err := writer.WriteField(parameter.Name, parameter.Value); err != nil {
log.Fatalf("failed to writer.WriteField:%v", err)
}
}
file, err := os.Open(imageURLs[i])
if err != nil {
log.Fatalf("failed to open file: %v", err)
}
defer file.Close()
wp, err := writer.CreateFormFile("file", filepath.Base(imageURLs[i]))
if err != nil {
log.Fatalf("failed to writer.CreateFormFile:%v", err)
}
_, err = io.Copy(wp, file)
if err != nil {
log.Fatalf("failed to io.Copy: %v", err)
}
writer.Close()
req, err := http.NewRequest(http.MethodPost, stagedTarget.URL, &requestBody)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := httpClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// 次のステップ(画像ファイル作成)で必要なので作成しておく
files = append(files, File{
ContentType: "IMAGE",
OriginalSource: stagedTarget.ResourceURL,
})
}
// ステップ3: Shopifyに画像ファイル作成
_, err = client.FileCreate(files)
if err != nil {
log.Fatal(err)
}
}
type GraphQLAPIClient struct {
client *shopify.Client
}
type ShopifyRegister struct {
Graphql *GraphQLAPIClient
}
func NewGraphQLAPIClient(shopifyHandle, authAccessToken string) *GraphQLAPIClient {
gqlClient := graphqlclient.NewClient(shopifyHandle, graphqlclient.WithToken(authAccessToken), graphqlclient.WithVersion("2024-10"))
client := shopify.NewClient(shopify.WithGraphQLClient(gqlClient))
return &GraphQLAPIClient{
client: client,
}
}
type Parameter struct {
Name string `json:"name"`
Value string `json:"value"`
FilePath string `json:"filePath"`
}
type StagedTarget struct {
URL string `json:"url"`
ResourceURL string `json:"resourceUrl"`
Parameters []Parameter `json:"parameters"`
}
type StagedUploadCreate struct {
StagedTargets []StagedTarget `json:"stagedTargets"`
}
type ResponseStagedUploadCreate struct {
StagedUploadsCreate StagedUploadCreate `json:"stagedUploadsCreate"`
}
type StagedUploadInput struct {
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
HttpMethod string `json:"httpMethod"`
Resource string `json:"resource"`
}
func (g *GraphQLAPIClient) StagedUploadsCreate(input []StagedUploadInput) (*ResponseStagedUploadCreate, error) {
query := `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
}
}`
variables := map[string]interface{}{
"input": input,
}
res := &ResponseStagedUploadCreate{}
if err := g.client.GraphQLClient().MutateString(context.Background(), query, variables, res); err != nil {
return nil, err
}
return res, nil
}
type File struct {
ContentType string `json:"contentType"`
OriginalSource string `json:"originalSource"`
}
type ResponseFile struct {
Id string `json:"id"`
FileStatus string `json:"fileStatus"`
Alt string `json:"alt"`
CreatedAt string `json:"createdAt"`
}
type ResponseFileCreate struct {
FileCreate struct {
Files []ResponseFile `json:"files"`
} `json:"fileCreate"`
}
func (g *GraphQLAPIClient) FileCreate(files []File) (*ResponseFileCreate, error) {
query := `mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
fileStatus
createdAt
... on MediaImage {
id
}
fileErrors {
code
message
}
}
}
}`
variables := map[string]interface{}{
"files": files,
}
res := &ResponseFileCreate{}
if err := g.client.GraphQLClient().MutateString(context.Background(), query, variables, res); err != nil {
return nil, err
}
return res, nil
}
HTTP プロトコルの基本的な部分について理解する
さてここからこの記事の本題?かもしれません。
公式ドキュメントでステップ 2 の実装について調査していると見慣れない curl コマンドの -F オプションが出てきました。
最初はこのオプションについて確認していたのですが、色々調べているとそもそも HTTP の基本的な仕様も理解できていなかったな〜と思い振り返りとして学んでみました。
出典: 公式ドキュメント
curl -v \
-F "Content-Type=image/png" \
-F "success_action_status=201" \
-F "acl=private" \
-F "key=tmp/45732462614/products/7156c27e-0331-4bd0-b758-f345afaa90d1/watches_comparison.png" \
-F "x-goog-date=20221024T181157Z" \
-F "x-goog-credential=merchant-assets@shopify-tiers.iam.gserviceaccount.com/20221024/auto/storage/goog4_request" \
-F "x-goog-algorithm=GOOG4-RSA-SHA256" \
-F "x-goog-signature=039cb87e2787029b56f498beb2deb3b9c34d96da642c1955f79225793f853760906abbd894933c5b434899d315da13956b1f67d8be54f470571d7ac1487621766a2697dfb8699c57d4e67a8b36ea993fde0f888b8d1c8bd3f33539d8583936bc13f9001ea3e6d401de6ad7ad2ae52d722073caf250340d5b0e92032d7ad9e0ec560848b55ec0f943595578a1d6cae53cd222d719acb363ba2c825e3506a52b545dec5be57074f8b1b0d58298a0b4311016752f4cdb955b89508376c38f8b2755fce2423acb3f592a6f240a21d8d2f51c5f740a61a40ca54769a736d73418253ecdf685e15cfaf7284e6e4d5a784a63d0569a9c0cffb660028f659e68a68fb80e" \
-F "policy=eyJjb25kaXRpb25zIjpbeyJDb250ZW50LVR5cGUiOiJpbWFnZVwvcG5nIn0seyJzdWNjZXNzX2FjdGlvbl9zdGF0dXMiOiIyMDEifSx7ImFjbCI6InByaXZhdGUifSxbImNvbnRlbnQtbGVuZ3RoLXJhbmdlIiwxLDIwOTcxNTIwXSx7ImJ1Y2tldCI6InNob3BpZnktc3RhZ2VkLXVwbG9hZHMifSx7ImtleSI6InRtcFwvZ2NzXC80NTczMjQ2MjYxNFwvcHJvZHVjdHNcLzcxNTZjMjdlLTAzMzEtNGJkMC1iNzU4LWYzNDVhZmFhOTBkMVwvd2F0Y2hlc19jb21wYXJpc29uLnBuZyJ9LHsieC1nb29nLWRhdGUiOiIyMDIyMTAyNFQxODExNTdaIn0seyJ4LWdvb2ctY3JlZGVudGlhbCI6Im1lcmNoYW50LWFzc2V0c0BzaG9waWZ5LXRpZXJzLmlhbS5nc2VydmljZWFjY291bnQuY29tXC8yMDIyMTAyNFwvYXV0b1wvc3RvcmFnZVwvZ29vZzRfcmVxdWVzdCJ9LHsieC1nb29nLWFsZ29yaXRobSI6IkdPT0c0LVJTQS1TSEEyNTYifV0sImV4cGlyYXRpb24iOiIyMDIyLTEwLTI1VDE4OjExOjU3WiJ9" \
-F "file=@/Users/shopifyemployee/Desktop/watches_comparison.png" \
"https://shopify-staged-uploads.storage.googleapis.com/"
HTTP について
そもそもの HTTP について調べてみました。
-
HTTP(HyperText Transfer Protocol) とは HTML ファイルや画像や動画、音声などのコンテンツの送受信で用いられるプロトコル(通信のルール/取り決め)
- TCP のアプリケーション層に位置するプロトコル
- もともとは Web ページを閲覧するためにハイパーテキストと呼ばれるデータを転送するためのもので、テキストファイルのダウンロードしかできかった
- HTTP クライアント(例:Web ブラウザ)から HTTP サーバー(例: Web サーバー)に要求(リクエスト) を行い、HTTP サーバーは要求に応じた応答(レスポンス) を HTTP クライアントに返す、リクエスト-レスポンス型のプロトコル
- HTTP でやりとりするメッセージをHTTP メッセージという
- クライアントからサーバーへのメッセージはリクエストメッセージ、サーバーからクライアントへの応答はレスポンスメッセージと呼ばれている
HTTP メッセージのフォーマットについて理解する
次にやりとりをする HTTP メッセージがどのような構成になっているのかフォーマットについて調べてみます。
リクエストとレスポンスどちらも同じ構成になっています。
RFC9112 でフォーマット仕様の確認ができます。仕様を確認すると、スタートラインと空行は必須で、メッセージヘッダーは最低一つ存在する必要があり、メッセージボディは省略可能であることが分かります。
-
スタートライン
- 一行でメッセージの種類を表す
-
メッセージヘッダー
- HTTP の制御情報が複数行にわたって記述されている
-
key: value
の形式で記述されている
-
空行
- メッセージヘッダーとメッセージボディの境界線を示す空行の改行コード(\r\n)
-
メッセージボディ
- 画像やファイルフォームデータなど実際に送りたいアプリケーションのデータが入るフィールド
HTTP メッセージのフォーマットイメージ
リクエストメッセージのフォーマット
次にリクエストメッセージについて確認します。
リクエストの場合スタートラインはリクエストラインと呼ばれています。その他に複数のHTTP ヘッダーで構成されたメッセージヘッダー、メッセージボディの 3 つで構成されています。
-
リクエストライン
- クライアントがサーバーに対して処理を依頼する行。リクエストメッセージにしか存在しない
- リクエストの種類を表す「メソッド」、リソースの識別子を表す「リクエスト URI」、「HTTP バージョン」が一行で記述されている
-
POST / HTTP/2
の場合POST
がメソッド、/
が識別子、HTTP/2
が HTTP バージョンを表す - 識別子はサーバーの場所やファイル名、パラメータなど色々なリソースを識別する文字列で絶対 URIか相対 URIのどちらかで記述される
- RFC3986 で規格化されている
-
HTTP ヘッダー
- 4 種類のいずれかで構成され、どの HTTP ヘッダーで構成されるかはクライアントによって違うそう
- リクエストヘッダー
- リクエストメッセージを制御するためのヘッダー
- 一般ヘッダー 汎用的に使用されるヘッダー
- エンティティヘッダー(表現ヘッダー)
- メッセージボディに関連する制御情報を含むヘッダー
- その他のヘッダー
- 分類はできないがよく使われるヘッダー
- リクエストヘッダー
- 4 種類のいずれかで構成され、どの HTTP ヘッダーで構成されるかはクライアントによって違うそう
リクエストメッセージフォーマットイメージ
今回のステップ 2 で実装したリクエストを curl で実行してみて中身をみると次のようなリクエストメッセージで構成されていることを確認しました。
> POST / HTTP/2 // リクエストライン
> Host: shopify-staged-uploads.storage.googleapis.com // リクエストヘッダー
> User-Agent: curl/8.4.0 // リクエストヘッダー
> Accept: */* // リクエストヘッダー
> Content-Length: 20569 // リクエストヘッダー
> Content-Type: multipart/form-data; boundary=------------------------MqawtoztIm9ZZ4940hKmwj // リクエストヘッダー
-- 空行 --
レスポンスメッセージのフォーマット
次にレスポンスメッセージについて確認します。
スタートラインは一行のステータスラインで、メッセージヘッダーは複数のHTTP ヘッダー、そしてメッセージボディの 3 つで構成されています。
-
ステータスライン
- サーバーがクライアントに対して処理結果の概要を返す行。レスポンスメッセージにしか存在しない
- 処理の概要を表す「ステータスコード」、その理由を表す「リーズンフレーズ」「HTTP バージョン」で構成されている
-
HTTP/1.1 200 OK
の場合HTTP/1.1
が HTTP バージョン、200
がステータスコード、OK
がリーズンフレーズを表す
-
HTTP ヘッダー
- 4 種類のいずれかで構成され、どの HTTP ヘッダーで構成されるかはサーバーによって違うそう
- レスポンスヘッダー
- レスポンスメッセージを制御するためのヘッダー
- 一般ヘッダー
- エンティティヘッダー(表現ヘッダー)
- その他のヘッダー
- レスポンスヘッダー
- 4 種類のいずれかで構成され、どの HTTP ヘッダーで構成されるかはサーバーによって違うそう
レスポンスメッセージフォーマットイメージ
今回のステップ 2 で実装したレスポンスの中身をみていると次のようなレスポンスメッセージで構成されていることを確認しました。
< HTTP/2 201 // ステータスライン
< x-guploader-uploadid: xxxxxxxxxxxxxxxxxxxxx // レスポンスヘッダー
< vary: Origin // レスポンスヘッダー
< content-length: 375 // レスポンスヘッダー
< date: Sat, 11 Jan 2025 05:45:58 GMT // レスポンスヘッダー
< server: UploadServer // レスポンスヘッダー
< content-type: text/html; charset=UTF-8 // レスポンスヘッダー
< alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 // レスポンスヘッダー
< // 空行
< <?xml version='1.0' encoding='UTF-8'?>...% // メッセージボディ
Content-Type について
HTTP ヘッダーのうちの一つである 表現ヘッダーの一つ で、メッセージ本体のリソースに関するメタデータ(エンコード方式、メディア種別、など)を持ちます。
mdn web docs の説明がわかりやすかったので引用します。
HTTP の Content-Type は表現ヘッダーで、コンテンツへのエンコードが適用される前の、リソースの元のメディア種別を示すために使用します。
レスポンスにおいては、 Content-Type ヘッダーはクライアントに返されたコンテンツの実際の種類を伝えます。 POST や PUT などのリクエストにおいては、クライアントは Content-Type ヘッダーを使用してサーバーに送信しようとしているコンテンツの種類を指定します。
このように種別を指定することでデータを適切にパースしたり処理したりできようになるということです。
Content-Type で指定するメディア種別というのは MIME type(メディアタイプ/コンテンツタイプ)とも呼ばれており、ファイルの種類を示す文字列になります。
top-level type/subtype
という文字列で表現することでメディア種別を一意に識別できます。
例:image/jpeg
と image/png
また パラメーター という任意の引数を使用できます,
text/html; charset=UTF-8
のようにして文字エンコーディングを指定したりもできます。
ファイルアップロードの仕様について理解する
HTTP プロトコルの基本的なところを学んだところで最後に HTTP プロトコルでファイルをアップロードする際の仕様について確認していきます。
curl -F オプションについて知る
-F オプションは --form
オプションのことで curl のドキュメント にも記載されているようにファイルのアップロードをより適切にサポートするためのオプションのようです。
このオプションを使用すると HTTP ヘッダーの Content-Type が multipart/form-data
であることを明確に示すことになります。
multipart/form-data
は HTML のフォームデータを扱う時に指定でき、他にも application/x-www-form-urlencoded
という MIME type も存在しています。
application/x-www-form-urlencoded
はテキストデータのみフォームデータを送信できるのに対して multipart/form-data
はテキストデータとファイルデータも送信できます。
multipart/form-data について理解する
実際に実行した時の multipart/form-data
の中身を見ながら確認していきます。
仕様は RFC7578 で定義されており、いくつか重要そうなものをピックアップしました。
中身を見ながら確認するとイメージがつきやすいかと思います。
- multipart/form-data ではboundaryという必須パラメーターが存在する
- これは境界パラメーターでリクエストボディを区切る役割を持つ
- 正確には
--{boundaryの値}{CRLF}
という文字列が境界線の役割を持つ
- 境界はパートという単位で区切られ、各パートにはContent-Dispositionというヘッダーが含まれている必要がある(必須)
- また Content-Disposition ヘッダー name パラメーターは必須でありフォームのフィールド名を指定する
- パートがファイルを表す場合、filename パラメーターをつけることが推奨される
- パートごとに MIME type を設定できる
- デフォルトが text/plain で設定は任意。ファイルを表すパートはデフォルトが application/octet-stream となるので指定することが推奨される
Content-Type: multipart/form-data; boundary=------------------------fNb9a503WCsvfYgpTlf1Lh
## -- + ------------------------fNb9a503WCsvfYgpTlf1Lh + CRLF(改行コード)でパート単位でリクエストボディの境界線を作っている
=> Send data, 20569 bytes (0x5059)
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="Content-Type"
image/png
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="success_action_status"
201
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="acl"
private
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="x-goog-date"
20250113T133139Z
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="x-goog-credential"
merchant-assets@shopify-tiers.iam.gserviceaccount.com/20250113/a
uto/storage/goog4_request
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="x-goog-algorithm"
GOOG4-RSA-SHA256
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="x-goog-signature"
a910843f9f44c59c9fef7732b6180f387b3025e2d8c79577b5ffb91ca782246f
697850488025154df542d7ea6d3430cff2c2fxxxxxxxxxxxxxxxxxxxxxxxxxxx
--------------------------fNb9a503WCsvfYgpTlf1Lh
Content-Disposition: form-data; name="policy"
eyJjb25kaXRpb25zIjpbeyJDb250ZW50LVR5cGUiOiJpbWFnZVwvcG5nIn0seyJz
dWNjZXNzX2FjdGlvbl9zdGF0dXMixxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
--------------------------fNb9a503WCsvfYgpTlf1L
# ファイルの場合はfilenameの指定が推奨されている
Content-Disposition: form-data; name="file"; filename="test1.png"
Content-Type: image/png
.PNG
...
...
# boundaryの最後には「--」をつけて終了を明示する
--------------------------fNb9a503WCsvfYgpTlf1Lh--
まとめ
ここまで少し細かい部分も見てきましたが実装の振り返りとして次のことが理解できるようになりました!
また一度 HTTP プロトコルを調べてからアップロードの実装を振り返るとよりイメージができるようになった感覚がありました。
特に最初みたときに理解できなかった curl -F を Go 言語では multipart パッケージ を使って実装しましたがその処理の理解度も調べる前と後ではかなり違いました。
- Shopify GraphQL(Go)を使って流れを理解しながら画像アップロードする
-
curl -F
の意味 -
multipart/form-data
の仕様
参考記事
curl の-d やら-F やらがよく分からない
curl の POST オプション -d と -F の違いから、改めて MIME type を学ぶ
curl ドキュメント
進化する HTTP の歩み
HTTP Semantics
Shopify アプリから画像を Shopify にアップロードする
Discussion