GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例

## お題
GraphQLでは、（まあ、要するにmultipartなわけだけど）`Upload` という名のscalarでスキーマ定義することでファイルアップロードを実現する。
この機能を使ったお題として、表題の組み合わせでやってみた。

## 機能
### 概要
- ローカル環境にある動画ファイル(mp4限定)をVideoタグでプレビュー確認後、Google Cloud Storageにアップロード
- Google Cloud Storage上のファイルを署名付きURLを用いてVideoタグでプレビュー表示

### 実際の挙動

こんな画面を用意する。赤枠をクリックすると、
![screenshot-localhost_3000-2020.04.19-22_57_41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/a7344e2d-4230-9f4e-8a27-66f6868363c8.png)

ローカル上のファイル（選択可能なのは mp4 だけに制御）を選択させる。例えば、「`jesse_wood-contrast.mp4`」を選択する。
![Screenshot at 2020-04-19 23-00-34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/da3c960c-00b1-8f0d-b14e-2babcb8aa056.png)

選択結果が画面に反映される。動画の再生ボタンを押下すると、
![screenshot-localhost_3000-2020.04.19-23_07_49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/49ca5191-a7ab-d000-bc35-30f757810037.png)

（まだサーバには未反映の）動画がプレビューできる。
そして、Name欄を埋め、「登録」ボタンを押下すると、
![screenshot-localhost_3000-2020.04.19-23_11_29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/65c903ae-a855-5709-1509-58abcc758721.png)

動画が登録され、一覧に表示される。
※ちなみに、動画の秒数はローカル上のファイル選択時にVideoタグの機能でメタ情報から取得
※ID欄はバックエンドでのDB登録時に自動でUUIDを採番
![screenshot-localhost_3000-2020.04.19-23_15_04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/80a9c45e-bd8b-0a65-295d-90db1b21acde.png)

適当にもう２件ほど動画をアップロードしてみた結果はこんな感じ。（一斉に動画プレビューも可能）
![screenshot-localhost_3000-2020.04.19-23_19_31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/f4df65b5-2b2b-a6b8-13ea-438555afd6f8.png)

動画はGoogle Cloud Storage上にこのようにアップロードされている。
![screenshot-console.cloud.google.com-2020.04.19-23_21_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/1b1e23cf-c0c0-509d-2f00-0a66d81b4587.png)

DBには、このように格納されている。
![Screenshot at 2020-04-19 23-23-59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180599/e4ef88d1-6f3a-05b2-ce19-957812417edb.png)

# 関連記事索引
- 第12回「[GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)](https://qiita.com/sky0621/items/957c010ba01c7a756ef7)」
- 第11回「[Dataloadersを使ったN+1問題への対応](https://qiita.com/sky0621/items/621f075e4257270a9e02)」
- 第10回「[GraphQL(gqlgen)エラーハンドリング](https://qiita.com/sky0621/items/affc833e770fee8b576e)」
- 第９回「[GraphQLにおける認証認可事例（Auth0 RBAC仕立て）](https://qiita.com/sky0621/items/efbb8fa085094230d0fd)」
- 第８回「[GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例](https://qiita.com/sky0621/items/5c2c565678c102d9d4ed)」
- 第７回「[GraphQLにおけるRelayスタイルによるページング実装（後編：フロントエンド）](https://qiita.com/sky0621/items/fdcdaefeef7cf4b750ac)」
- 第６回「[GraphQLにおけるRelayスタイルによるページング実装（前編：バックエンド）](https://qiita.com/sky0621/items/1e8823200633f2c46013)」
- 第５回「[DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動](https://qiita.com/sky0621/items/58fa2bcfc0935f6186cb)」
- 第４回「[graphql-codegenでフロントエンドをGraphQLスキーマファースト](https://qiita.com/sky0621/items/f03631f0309f5c375b1d)」
- 第３回「[go+gqlgenでGraphQLサーバを作る（GORM使ってDB接続）](https://qiita.com/sky0621/items/32e3e26189589080e492)」
- 第２回「[NuxtJS(with Apollo)のTypeScript対応](https://qiita.com/sky0621/items/0297f5a026be380a5929)」
- 第１回「[frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る](https://qiita.com/sky0621/items/8abd445edba347e8f6f1)」

# 開発環境
## # OS - Linux(Ubuntu)
```
$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.4 LTS (Bionic Beaver)"
```

## # フロントエンド
### Nuxt.js
```
$ cat yarn.lock | grep "@nuxt/vue-app"
    "@nuxt/vue-app" "2.12.2"
"@nuxt/vue-app@2.12.2":
  resolved "https://registry.yarnpkg.com/@nuxt/vue-app/-/vue-app-2.12.2.tgz#cc4b68356996eb71d398a30f3b9c9d15f7d531bc"
```

### パッケージマネージャ - Yarn
```
$ yarn -v
1.22.4
```

### IDE - WebStorm
```
WebStorm 2020.1
Build #WS-201.6668.106, built on April 7, 2020
```

## # バックエンド
### Golang
```
$ go version
go version go1.13.9 linux/amd64
```

### IDE - Goland
```
GoLand 2020.1
Build #GO-201.6668.125, built on April 9, 2020
```

# 前提
以下技術の導入手順については特に説明しない。（ググれば、いくらでもわかりやすい資料は出てくるので）

- GraphQL
- Vue.js/Nuxt.js
- TypeScript
- Vuetify.js
- Apollo
- Golang
- gqlgen
- Google Cloud Platform全般

また、上記を前提にするので、Google Cloud Storageにアクセスする上で必要となるサービスアカウントの発行やらクレデンシャルの使い方なども特に説明しない。

さらに、今回、docker-compose を使って PostgreSQL コンテナを立ち上げ、動画情報の永続先としているのだけど、Docker関連のセットアップやらなども特に説明しない。

# 実践

## ■全ソース
### バックエンド（GraphQLサーバ）
https://github.com/sky0621/fs-mng-backend/tree/v0.1
### フロントエンド
https://github.com/sky0621/fs-mng-frontend/tree/v0.1

## ■個別ソース説明
### バックエンド
#### ディレクトリ構成
```
$ tree
.
├── README.md
├── go.mod
├── go.sum
├── local
│   ├── docker
│   │   └── db
│   │       └── init
│   │           └── 1_init.sql
│   └── docker-compose.yml
├── schema
│   ├── movie.graphqls
│   └── schema.graphqls
└── src
    ├── cmd
    │   └── main.go
    ├── gcp
    │   └── cloud_storage_client.go
    ├── gqlgen.yml
    ├── graph
    │   ├── generated
    │   │   └── generated.go
    │   ├── model
    │   │   └── models_gen.go
    │   ├── movie.resolvers.go
    │   ├── resolver.go
    │   └── schema.resolvers.go
    ├── models
    │   ├── boil_main_test.go
    │   ├── boil_queries.go
    │   ├── boil_queries_test.go
    │   ├── boil_suites_test.go
    │   ├── boil_table_names.go
    │   ├── boil_types.go
    │   ├── movie.go
    │   ├── movie_test.go
    │   ├── psql_main_test.go
    │   ├── psql_suites_test.go
    │   └── psql_upsert.go
    ├── sqlboiler.toml
    └── util
        └── time_util.go
```

#### GraphQLスキーマ

```graphql:schema/schema.graphqls
# Global Object Identification ... 全データを共通のIDでユニーク化
interface Node {
    id: ID!
}

schema {
    query: Query
    mutation: Mutation
}

type Query {
    node(id: ID!): Node
}

type Mutation {
    noop(input: NoopInput): NoopPayload
}

input NoopInput {
    clientMutationId: String
}

type NoopPayload {
    clientMutationId: String
}

type MutationResponse {
    id: ID
}
```

```graphql:schema/movie.graphqls
extend type Mutation {
    "動画をアップロード"
    createMovie(input: MovieInput!): MutationResponse
}

input MovieInput {
    "名称"
    name: String!
    "動画URL"
    movieFile: Upload!
    "秒数"
    scale: Int!
}

"動画アップロード用"
scalar Upload

extend type Query {
    movies: [Movie!]!
}

type Movie implements Node {
    "UUID"
    id: ID!
    "名称"
    name: String!
    "動画URL"
    movieUrl: String!
    "秒数"
    scale: Int!
}
```

機能として「動画を登録する」ものと「動画(情報)を一覧表示する」ものを用意しているので `mutation` と `query` を１つずつ用意。
情報としては特にひねりはない。multipartの動画情報受け口として `Upload` を型とする要素を定義しているのがポイント。

#### main関数

DBは [SQL Boiler](https://github.com/volatiletech/sqlboiler) というO/Rマッパーを使用。
Google Cloud Storageにアクセスするのでそのためのクライアント初期化処理も実行。
Webサーバとしての機能は [chi](https://github.com/go-chi/chi) というフレームワークを使用。
GraphQLなのでエンドポイントは１つ。ハンドリングは [gqlgen](https://github.com/99designs/gqlgen) というライブラリで行う。

```go:src/cmd/main.go
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/99designs/gqlgen/graphql/handler/transport"

	"github.com/sky0621/fs-mng-backend/src/gcp"

	"github.com/99designs/gqlgen/graphql/playground"

	"github.com/rs/cors"

	"github.com/go-chi/chi"

	"github.com/99designs/gqlgen/graphql/handler"
	_ "github.com/lib/pq"
	"github.com/sky0621/fs-mng-backend/src/graph"
	"github.com/sky0621/fs-mng-backend/src/graph/generated"
	"github.com/volatiletech/sqlboiler/boil"
)

const defaultPort = "5050"

func main() {
	/*
	 * setup db client
	 */
	var db *sql.DB
	{
		var err error
		// MEMO: ひとまずローカルのコンテナ相手の接続前提なので、べたに書いておく。
		db, err = sql.Open("postgres", "dbname=localdb user=postgres password=localpass sslmode=disable port=19999")
		if err != nil {
			panic(err)
		}
		defer func() {
			if db != nil {
				if err := db.Close(); err != nil {
					panic(err)
				}
			}
		}()

		boil.DebugMode = true

		var loc *time.Location
		loc, err = time.LoadLocation("Asia/Tokyo")
		if err != nil {
			panic(err)
		}
		boil.SetLocation(loc)
	}

	/*
	 * setup GCP client
	 */
	gcsClient, err := gcp.NewCloudStorageClient(context.Background(), os.Getenv("BUCKET"))
	if err != nil {
		panic(err)
	}

	/*
	 * setup web server
	 */
	var router *chi.Mux
	{
		router = chi.NewRouter()

		cors := cors.New(cors.Options{
			AllowedOrigins:   []string{"*"},
			AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
			AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
			ExposedHeaders:   []string{"Link"},
			AllowCredentials: true,
			MaxAge:           300, // Maximum value not ignored by any of major browsers
		})
		router.Use(cors.Handler)

		srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{
			Resolvers: &graph.Resolver{
				DB:        db,
				GCSClient: gcsClient,
			},
		}))
		var mb int64 = 1 << 20
		srv.AddTransport(transport.MultipartForm{
			MaxMemory:     128 * mb,
			MaxUploadSize: 100 * mb,
		})

		router.Handle("/", playground.Handler("fs-mng-backend", "/query"))
		router.Handle("/query", srv)
	}

	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}

	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
	if err := http.ListenAndServe(":"+port, router); err != nil {
		fmt.Println(err)
	}
}
```

#### Google Cloud Storage クライアント

今回は、GCSへのファイルアップロード及び（一時的な）動画参照用の署名付きURL生成機能が必要なので、それぞれをメソッドとして用意。

```go:src/gcp/cloud_storage_client.go
package gcp

import (
	"context"
	"io"
	"net/http"
	"time"

	"cloud.google.com/go/storage"
	"golang.org/x/oauth2/google"
	"golang.org/x/xerrors"
)

type CloudStorageClient interface {
	ExecSignedURL(object string, expire time.Time) (url string, err error)
	ExecUploadObject(object string, reader io.Reader) error
}

type cloudStorageClient struct {
	bucket           string
	signedURLFunc    SignedURLFunc
	uploadObjectFunc UploadObjectFunc
}

func NewCloudStorageClient(ctx context.Context, bucket string) (CloudStorageClient, error) {
	var credentialJSON []byte
	{
		credential, err := google.FindDefaultCredentials(ctx, storage.ScopeReadOnly)
		if err != nil {
			return nil, xerrors.Errorf("failed to google.FindDefaultCredentials: %w", err)
		}
		if credential == nil || credential.JSON == nil {
			return nil, xerrors.New("defaultCredentials is nil")
		}
		credentialJSON = credential.JSON
	}

	conf, err := google.JWTConfigFromJSON(credentialJSON, storage.ScopeReadOnly)
	if err != nil {
		return nil, xerrors.Errorf("failed to google.JWTConfigFromJSON: %w", err)
	}
	if conf == nil {
		return nil, xerrors.New("config is nil")
	}

	var options = storage.SignedURLOptions{
		GoogleAccessID: conf.Email,
		PrivateKey:     conf.PrivateKey,
		Method:         http.MethodGet,
	}
	return &cloudStorageClient{
		bucket: bucket,
		signedURLFunc: func(bucket, object string, expire time.Time) (string, error) {
			options.Expires = expire
			url, err := storage.SignedURL(bucket, object, &options)
			if err != nil {
				return "", xerrors.Errorf("failed to storage.SignedURL(bucket:%s, object:%s): %w", bucket, object, err)
			}
			return url, nil
		},
		uploadObjectFunc: func(bucket, object string, reader io.Reader) error {
			client, err := storage.NewClient(ctx)
			if err != nil {
				return xerrors.Errorf("failed to storage.NewClient: %w", err)
			}
			wc := client.Bucket(bucket).Object(object).NewWriter(ctx)
			wc.ObjectAttrs.ContentType = "application/octet-stream"
			wc.ObjectAttrs.CacheControl = "no-cache"
			if _, err = io.Copy(wc, reader); err != nil {
				return xerrors.Errorf("failed to storage.NewClient.Bucket(%s).Object(%s).NewWriter: %w", bucket, object, err)
			}
			defer func() {
				if wc == nil {
					return
				}
				err = wc.Close()
			}()
			return nil
		},
	}, nil
}

func (c *cloudStorageClient) ExecSignedURL(object string, expire time.Time) (string, error) {
	if c == nil || object == "" {
		return "", xerrors.Errorf("does not meet the preconditions: [object:%s]", object)
	}
	return c.signedURLFunc(c.bucket, object, expire)
}

func (c *cloudStorageClient) ExecUploadObject(object string, reader io.Reader) error {
	if c == nil || object == "" || reader == nil {
		return xerrors.Errorf("does not meet the preconditions: [object:%s]", object)
	}
	return c.uploadObjectFunc(c.bucket, object, reader)
}

type SignedURLFunc func(bucket, object string, expire time.Time) (url string, err error)
type UploadObjectFunc func(bucket, object string, reader io.Reader) error
```

#### リゾルバー

GraphQLリクエストを処理する部分。

```go:src/graph/resolver.go
package graph

import (
	"database/sql"

	"github.com/sky0621/fs-mng-backend/src/gcp"
)

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

type Resolver struct {
	DB        *sql.DB
	GCSClient gcp.CloudStorageClient
}
```

```go:src/graph/movie.resolvers.go
package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.

import (
	"context"
	"fmt"
	"time"

	"github.com/google/uuid"

	"github.com/sky0621/fs-mng-backend/src/graph/model"
	. "github.com/sky0621/fs-mng-backend/src/models"
	"github.com/sky0621/fs-mng-backend/src/util"
	"github.com/volatiletech/sqlboiler/boil"
	"golang.org/x/xerrors"
)

func (r *mutationResolver) CreateMovie(ctx context.Context, input model.MovieInput) (*model.MutationResponse, error) {
	// トランザクションを貼る
	tx, err := r.DB.BeginTx(ctx, nil)
	if err != nil {
		return nil, xerrors.Errorf("failed to BeginTx: %w", err)
	}
	defer func() {
		if tx != nil {
			// コミット発行されてなければ必ずロールバックされる
			if err := tx.Rollback(); err != nil {
				fmt.Println(err)
			}
		}
	}()

	// DB登録
	m := Movie{
		ID:       uuid.New().String(),
		Name:     input.Name,
		Filename: input.MovieFile.Filename,
		Scale:    input.Scale,
	}
	if err := m.Insert(ctx, r.DB, boil.Infer()); err != nil {
		// トランザクションロールバックされる
		return nil, xerrors.Errorf("failed to Insert: %w", err)
	}

	// ファイルをCloud Storageにアップ
	if err := r.GCSClient.ExecUploadObject(input.MovieFile.Filename, input.MovieFile.File); err != nil {
		// トランザクションロールバックされる
		return nil, xerrors.Errorf("failed to GCSClient.ExecUploadObject: %w", err)
	}

	if err := tx.Commit(); err != nil {
		// トランザクションロールバックされる
		return nil, xerrors.Errorf("failed to Commit: %w", err)
	}

	return &model.MutationResponse{
		ID: &m.ID,
	}, nil
}

func (r *queryResolver) Movies(ctx context.Context) ([]*model.Movie, error) {
	records, err := Movies().All(ctx, r.DB)
	if err != nil {
		return nil, xerrors.Errorf("failed to Movies ALL: %w", err)
	}

	var results []*model.Movie
	for _, record := range records {
		url, err := r.GCSClient.ExecSignedURL(record.Filename, util.GetExpire(30*time.Second))
		if err != nil {
			return nil, xerrors.Errorf("failed to GCSClient.ExecSignedURL: %w", err)
		}
		results = append(results, &model.Movie{
			ID:       record.ID,
			Name:     record.Name,
			MovieURL: url,
			Scale:    record.Scale,
		})
	}
	return results, nil
}
```

ファイルアップロードの方は、一応、トランザクション貼って、DB登録を先に行い、その後のGCSへの動画アップロードがエラーになったらロールバックが効くようにしている。
動画一覧表示の方は、いったんDBから動画情報を取得した後、ファイル名を元にGCS上のファイルへの署名付きURLを１ファイルずつ生成してレスポンスに含めるようにしている。今回はとりあえず署名付きURLの期限を `30分` にしているので、期限到達後はフロントエンドでこのURLへのアクセスはできなくなる。

### フロントエンド
#### ディレクトリ構成
```
$ tree
.
├── README.md
├── apollo
│   ├── mutations
│   │   └── movies.gql
│   └── queries
│       └── movies.gql
├── assets
│   ├── README.md
│   └── variables.scss
├── codegen.yml
├── components
│   ├── MovieForm.vue
│   ├── MoviesCard.vue
│   └── README.md
├── gql-types.d.ts
├── jest.config.js
├── jsconfig.json
├── layouts
│   ├── README.md
│   ├── default.vue
│   └── error.vue
├── middleware
│   └── README.md
├── nuxt.config.js
├── package-lock.json
├── package.json
├── pages
│   ├── README.md
│   ├── index.vue
│   └── new.vue
├── plugins
│   ├── README.md
│   ├── apollo-error-handler.ts
│   └── filter.ts
├── static
│   ├── README.md
│   ├── favicon.ico
│   ├── v.png
│   └── vuetify-logo.svg
├── store
│   └── README.md
├── test
│   └── Logo.spec.js
├── tsconfig.json
├── types
│   └── vuetify
│       └── index.d.ts
├── vue-shim.d.ts
└── yarn.lock
```

#### GraphQL
#### 動画登録 mutation

```graphql:apollo/mutations/movies.gql
mutation CreateMovie($object: MovieInput!) {
  createMovie(input: $object) {
    id
  }
}
```

#### 動画一覧 query

```graphql:apollo/queries/movies.gql
query movies {
  movies {
    id
    name
    movieUrl
    scale
  }
}
```

#### 動画登録ページ
##### ページ
```vue:pages/new.vue
<template>
  <div>
    <MovieForm @submit="save" @cancel="moveToIndex" />
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import 'vue-apollo'
import MovieForm from '~/components/MovieForm.vue'
import { MovieInput } from '~/gql-types'
import CreateMovie from '~/apollo/mutations/movies.gql'

@Component({
  components: { MovieForm }
})
export default class NewPage extends Vue {
  async save(input: MovieInput) {
    try {
      const res = await this.$apollo.mutate({
        mutation: CreateMovie,
        variables: {
          object: {
            name: input.name,
            movieFile: input.movieFile,
            scale: input.scale
          }
        }
      })
      if (res) {
        console.log(res)
        await this.$router.push('/')
      } else {
        console.log('no res')
        this.$toast.error('no response')
      }
    } catch (err) {
      // TODO: エラーハンドリング実装！
      console.log(err)
      this.$toast.error(err)
    }
  }

  moveToIndex(): void {
    this.$router.push('/')
  }
}
</script>
```

動画情報登録フォームコンポーネントから`submit`の通知を受けたら、動画登録のmutationを実行するページ。

`Name`テキストボックスと動画プレビュー用Videoタグとファイル入力部品、そして「登録」ボタン（と「キャンセル」ボタン）とで構成。

##### コンポーネント
```vue:components/MovieForm.vue
<template>
  <v-container>
    <v-form>
      <v-row justify="center">
        <v-col md="auto">
          Name: <v-text-field v-model="input.name"></v-text-field>
          <video
            :src="uploadTargetUrl"
            width="800"
            height="450"
            controls
            @loadedmetadata="setMovieDuration"
          ></video>
          <v-file-input
            v-model="input.movieFile"
            accept=".mp4"
            @change="getUploadTargetFileUrl"
          ></v-file-input>
          <v-btn @click="save">登録</v-btn>
          <v-btn @click="cancel">キャンセル</v-btn>
        </v-col>
      </v-row>
    </v-form>
  </v-container>
</template>

<script lang="ts">
import { Vue, Component, Emit } from 'nuxt-property-decorator'
import 'vue-apollo'
import { MovieInput } from '~/gql-types'

class MovieInputImpl implements MovieInput {
  movieFile: any
  name!: string
  scale!: number
}

@Component({})
export default class MovieForm extends Vue {
  // 入力フォームの初期化
  input: MovieInput = new MovieInputImpl()

  // ローカルにある動画を取得したら、videoタグでプレビュー表示させるためにURL生成
  private uploadTargetUrl: string = ''
  getUploadTargetFileUrl(): void {
    if (this.input.movieFile) {
      this.uploadTargetUrl = URL.createObjectURL(this.input.movieFile)
    }
  }

  // ローカルにある動画のメタ情報を読み込んだタイイングで発火
  // 動画の秒数を取得し、登録フォームの要素に自動的に格納
  setMovieDuration(e: Event): void {
    if (e && e.target) {
      const t: any = e.target
      this.input.scale = Math.round(t.duration) // 少数->整数に四捨五入して丸める
    }
  }

  save() {
    // TODO: バリデーション実装
    this.$emit('submit', this.input)
  }

  @Emit('cancel')
  cancel(): void {}
}
</script>
```

`v-file-input`に`change`イベントを仕込んで、ローカルから動画ファイルが指定されたら、その動画ファイル名から `URL.createObjectURL` を用いてVideoタグのsrcとして指定可能なURLを生成し、Videoタグのsrcにセット。

Videoタグには`loadedmetadata`イベントを仕込んで、動画からメタ情報が取れるようになったら、秒数を（整数に丸めて）登録フォームの要素にセット。

#### 動画一覧ページ
##### ページ
```vue:pages/index.vue
<template>
  <div>
    <MoviesCard :movies="movies" @move="moveToNew" />
  </div>
</template>

<script lang="ts">
import { Vue, Component } from 'nuxt-property-decorator'
import 'vue-apollo'
import MoviesCard from '~/components/MoviesCard.vue'
import movies from '~/apollo/queries/movies.gql'
import { Movie } from '~/gql-types'

@Component({
  components: { MoviesCard },
  apollo: {
    movies: {
      prefetch: true,
      query: movies
    }
  }
})
export default class IndexPage extends Vue {
  private readonly movies: Movie[] = []

  moveToNew(): void {
    this.$router.push('/new')
  }
}
</script>
```

GraphQLクエリを介して、バックエンドから動画情報一覧を取得し、コンポーネントに渡す。

##### コンポーネント
```vue:components/MoviesCard.vue
<template>
  <v-container>
    <v-row justify="center">
      <v-col md="1">
        <v-btn fab @click="move">
          <v-icon>mdi-plus</v-icon>
        </v-btn>
      </v-col>
      <v-col md="auto">
        <v-data-table :items="movies" :headers="headers">
          <template v-slot:item.movieUrl="{ item }">
            <video
              :src="item.movieUrl"
              width="160"
              height="90"
              style="margin: 12px"
              controls
            ></video>
          </template>
          <template v-slot:item.scale="{ item }">
            {{ item.scale | scale }}
          </template>
        </v-data-table>
      </v-col>
    </v-row>
  </v-container>
</template>

<script lang="ts">
import { Vue, Component, Prop, Emit } from 'nuxt-property-decorator'
import { DataTableHeader } from '@/types/vuetify'
import 'vue-apollo'
import { Movie } from '~/gql-types'

@Component({})
export default class MoviesCard extends Vue {
  @Prop({ default: () => {} })
  readonly movies!: Movie[]

  get headers(): DataTableHeader[] {
    return [
      { text: 'ID', value: 'id' },
      { text: 'Name', value: 'name' },
      { text: 'Preview', value: 'movieUrl' },
      { text: 'Scale', value: 'scale' }
    ]
  }

  @Emit('move')
  move(): void {}
}
</script>
```

# まとめ
ファイルアップロード自体はGraphQLでやる必要性はないのだけど、GraphQLでもできるよという話。
ただ、実は以下、解決しないままいったん放置している。（折を見て、そのうち解消する予定）

- `@nuxt/toast` を導入したはいいけど、`this.$toast` がTypeScript的に(?)怒られているので `yarn dev` は動くけど、おそらく `yarn build` は動かないはず。
- ファイルの容量として `35MB` を超えたあたりからファイルアップロードでネットワークエラーが起きて正常終了しない。（おそらくNuxt、Apolloないしgqlgenの何かの制約に引っかかっている？）