s3_direct_multipart_upload 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +45 -0
  3. data/CODE_OF_CONDUCT.md +22 -0
  4. data/CONTRIBUTING.md +26 -0
  5. data/MIT-LICENSE +21 -0
  6. data/OPERATIONS.md +55 -0
  7. data/README.md +93 -0
  8. data/SPEC.md +62 -0
  9. data/app/controllers/s3_direct_multipart_upload/application_controller.rb +4 -0
  10. data/app/controllers/s3_direct_multipart_upload/dev/storage_controller.rb +62 -0
  11. data/app/controllers/s3_direct_multipart_upload/dev/storage_downloads_controller.rb +44 -0
  12. data/app/controllers/s3_direct_multipart_upload/upload_completions_controller.rb +32 -0
  13. data/app/controllers/s3_direct_multipart_upload/upload_parts_controller.rb +29 -0
  14. data/app/controllers/s3_direct_multipart_upload/upload_sessions_controller.rb +46 -0
  15. data/app/models/s3_direct_multipart_upload/upload_session.rb +394 -0
  16. data/app/models/s3_direct_multipart_upload/upload_session_part.rb +34 -0
  17. data/app/services/s3_direct_multipart_upload/dev/storage.rb +85 -0
  18. data/config/initializers/s3_direct_dev.rb +13 -0
  19. data/config/routes.rb +15 -0
  20. data/config/storage/development.yml +3 -0
  21. data/config/storage/test.yml +3 -0
  22. data/db/migrate/20251113090000_create_s3_direct_multipart_upload_tables.rb +32 -0
  23. data/doc/api.yml +256 -0
  24. data/doc/api_dev.yml +111 -0
  25. data/lib/generators/s3_direct_multipart_upload/install/install_generator.rb +14 -0
  26. data/lib/s3_direct_multipart_upload/engine.rb +16 -0
  27. data/lib/s3_direct_multipart_upload/version.rb +3 -0
  28. data/lib/s3_direct_multipart_upload.rb +9 -0
  29. data/lib/tasks/s3_direct_multipart_upload_tasks.rake +15 -0
  30. metadata +247 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 124641d70cf0b8880a27fac5adf24e75d83d7251ca1869cf975ef78700a909d7
4
+ data.tar.gz: 48df3b30a0bfb150ae30823e79932e7cc25a9c792041131ec1886ce3129ffb44
5
+ SHA512:
6
+ metadata.gz: bdea9f54aac5a1ef4dc4cdec4d81fddb29689a76157e464a2bf4f04e3967aed4ba7877eaed176b4bbf7308b8e44849a43c72ff47d7d5219ac6c448e28a9be3c3
7
+ data.tar.gz: 64c6ed9591a00d69d61d848894192534e6368aacef52ba04e280bf63101b9273acdc20cc92c51c5e13ac6cc725671a6cef9e03f4eef29ca212e24b997419ed3e
data/AGENTS.md ADDED
@@ -0,0 +1,45 @@
1
+ # 作業前の確認
2
+
3
+ - 作業ディレクトリおよび親ディレクトリに README.md / SPEC.md / OPERATIONS.md / AGENTS.md が存在する場合、必ずすべて読み指示と方針を把握してから着手すること。
4
+
5
+ # Style
6
+
7
+ - すべての説明、途中の思考プロセス、ステップバイステップの推論を日本語で書くこと。
8
+ - コードは通常どおり生成するが、補足説明や意図の説明もすべて日本語にする。
9
+ - 推論や方針の説明を省略せず、わかりやすく日本語で書く。
10
+
11
+ # ルール
12
+
13
+ ## 許可されるコマンド
14
+
15
+ - 副作用のない Read / Get / 自動テスト
16
+ - `bundle exec rspec`
17
+ - `bundle install`
18
+ - カレントディレクトリ以下のファイルの読み書き
19
+ - インターネット上に一般公開されている文章へのアクセス
20
+
21
+ ## 許可されないコマンド
22
+
23
+ - `git commit`, `git push`, `terraform apply`
24
+ - git に機密情報をコミットしてはいけない
25
+
26
+ ## 不明な場合
27
+
28
+ - コマンドが副作用を持つか不明、もしくは判断に迷う場合は、**必ず実行前にユーザーへ確認すること**
29
+ - 勝手に推測して実行してはならない
30
+
31
+ ## ユーザーが危険コマンドの実行を要求した場合
32
+
33
+ - 実行は拒否し、理由を説明する
34
+ - 代わりに、手動で実行する場合の注意点・手順を日本語で説明する
35
+
36
+ # プロジェクト固有の開発ガイド
37
+
38
+ - RuboCop: `bundle exec rubocop`(`.rubocop.yml` は omakase 基準、新Copは enable)。指摘は原則解消する。
39
+ - テスト: `bundle exec rspec`。SimpleCov を有効化しているため、カバレッジ(line/branch)100% を維持する方針。
40
+ - CI: GitHub Actions で rubocop → rspec の順に実行し、両方のパスを必須とする。
41
+ - 仕様の SoT: `SPEC.md`。運用の真実: `OPERATIONS.md`。意思決定は `ADR/`。概要と導線は `README.md`。
42
+ - 開発用エンドポイント(dev/storage 系)は development/test のみ有効。本番では無効。
43
+ - Disk + stub 環境では `create_multipart_upload` をユニーク `upload_id` で stub する初期化がある。変更時はテストも更新すること。
44
+ - 開発モードでパート結合後、`tmp/s3_direct_multipart_upload/<object_key>` に単一ファイルが生成される。動作確認は OPERATIONS.md の手順に従う。
45
+ - API 契約検証: request spec で committee-rails を用いて `doc/api.yml` / `doc/api_dev.yml` とレスポンスの整合を確認する(本番ミドルウェア適用はしない)。
@@ -0,0 +1,22 @@
1
+ # 行動規範
2
+
3
+ このプロジェクトは、すべての参加者にとって安全で歓迎されるコミュニティを目指します。以下を守ってください。
4
+
5
+ ## 期待する行動
6
+ - 尊重と敬意をもってコミュニケーションする。
7
+ - 根拠を示しながら建設的なフィードバックを行う。
8
+ - 包摂的な表現を心がけ、排他的・差別的な言動を避ける。
9
+ - レビューや議論では「人」ではなく「課題」にフォーカスする。
10
+
11
+ ## 容認しない行動
12
+ - 侮辱、差別、ハラスメント(性別、性的指向、年齢、人種、宗教、障がい等に関するものを含む)。
13
+ - 脅迫、個人情報の晒し、許可のない追跡や接触。
14
+ - 議論の場での継続的な妨害や悪意あるトロール行為。
15
+
16
+ ## 適用範囲
17
+ - この規範はリポジトリ、Issue/PR、レビューコメント、コミュニティスペースなど、プロジェクトに関わるすべての場に適用されます。
18
+
19
+ ## 連絡先と対応
20
+ - 行動規範に反する事象を見かけた場合は `develop@logmi.co.jp` まで連絡してください。必要に応じて記録やスクリーンショットを添付してください。
21
+ - メンテナは状況に応じて警告、コメント削除、投稿の一時停止、貢献の制限など適切な措置を講じます。
22
+ - 繰り返しの違反や悪質な行為が確認された場合、プロジェクトからの除外を含む厳正な対応を行います。
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,26 @@
1
+ # コントリビュートガイドライン
2
+
3
+ このプロジェクトへの貢献を歓迎します。以下の方針に従ってください。
4
+
5
+ ## 開発準備
6
+ - Ruby 3.2 以上で動作することを前提としています。
7
+ - 依存をインストール: `bundle install`
8
+ - 推奨チェック: `bundle exec rspec`
9
+
10
+ ## ブランチ運用と PR
11
+ - main ブランチは常にリリース可能な状態を保ちます。作業はトピックブランチで行ってください(例: `feature/<短い説明>`)。
12
+ - 1 PR は 1 つのテーマに絞り、変更点を簡潔にまとめた説明を記載してください。
13
+ - 破壊的変更や仕様変更を含む場合は、動機と影響範囲を明記してください。
14
+ - CI が通っていることを確認してからレビュー依頼を出してください。
15
+
16
+ ## コーディングとテスト
17
+ - 追加・変更分に対するテストを同時に用意してください(正常系・異常系の双方を考慮)。
18
+ - 表記・コメントは日本語で統一してください。
19
+ - 可能な限り既存のコードスタイルに合わせてください。
20
+
21
+ ## セキュリティとプライバシー
22
+ - 認証情報や個人情報を含むログやサンプルを PR に含めないでください。
23
+ - 脆弱性を発見した場合は、公開 Issue ではなく `develop@logmi.co.jp` まで報告してください。
24
+
25
+ ## ライセンス
26
+ - コントリビュートされたコードは MIT ライセンスの下で公開されることに同意したものとみなします。
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Logmi, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/OPERATIONS.md ADDED
@@ -0,0 +1,55 @@
1
+ # OPERATIONS
2
+
3
+ 開発・運用手順と日常オペレーションの真実を記録する。仕様は SPEC.md を参照。
4
+
5
+ ## 1. 環境要件
6
+ - Ruby 3.2.9(`.ruby-version`)
7
+ - Bundler (`bundle install`) → `vendor/bundle` へインストール
8
+ - AWS 依存: ActiveStorage サービス `s3_direct_multipart` 設定必須(`config/storage.yml` など)
9
+
10
+ ## 2. 開発セットアップ
11
+ 1. rbenv 等で Ruby 3.2.9 を有効化。
12
+ 2. `bundle install`
13
+ 3. テーブル適用(ホストアプリ側で実行): `bin/rails s3_direct_multipart_upload:install:migrations` → `bin/rails db:migrate`
14
+
15
+ ## 3. 日常コマンド
16
+ - テスト: `bundle exec rspec`
17
+ - RuboCop: `bundle exec rubocop`
18
+ - OpenAPI スキーマ検証: `bundle exec rspec spec/doc/openapi_spec.rb`(パース検証)、committee-rails によるレスポンス契約チェックは request spec 内で実行。
19
+ - S3 挙動整合性チェック: `bundle exec rspec spec/requests/dev/storage_spec.rb spec/requests/dev/storage_downloads_spec.rb` で、S3 の想定ステータス/ETag 返却(200 + ETag、署名不正/期限切れは 403、存在しない/未完了は 404/422)が dev エンドポイントで再現できているか確認する。
20
+ - CI: GitHub Actions で RuboCop → RSpec の順に実行(rubocop ジョブがパスしてから rspec ジョブを走らせる)。ruby/setup-ruby の bundler-cache を有効化。
21
+
22
+ ## 4. 開発モード(Disk + stub)でのアップロード検証
23
+ - ActiveStorage 設定: `service: Disk` かつ `stub_responses: true`。
24
+ - Presigned URL 発行→PUT: `PUT /s3_direct_multipart_upload/dev/storage/*path`
25
+ - 署名クエリ: `uploadId`, `partNumber`, `expires_at`, `signature`
26
+ - 保存先: `tmp/s3_direct_multipart_upload/<object_key>.parts/<partNumber>`
27
+ - ETag をレスポンスヘッダで返却
28
+ - 署名用環境変数:
29
+ - `S3_DIRECT_DEV_ENDPOINT` (default `http://localhost:3000`)
30
+ - `S3_DIRECT_DEV_SECRET` (default `dev-secret`)
31
+ - `create_multipart_upload` はユニーク `upload_id` を stub する(初期化で自動設定)。
32
+
33
+ ## 5. 開発モードでのダウンロード確認
34
+ - complete 後、Disk 環境ではパートを結合し `tmp/s3_direct_multipart_upload/<object_key>` に単一ファイルを生成。
35
+ - `GET /s3_direct_multipart_upload/dev/storage/:upload_session_id/download` で返却(development/test 限定)。
36
+ - 未完了/欠損時は 422、存在しなければ 404、非開発環境は 404。
37
+
38
+ ## 6. 本番運用上の注意
39
+ - 開発用ルート(dev/storage*, download)は development/test のみで有効。本番で開かないこと。
40
+ - `abort_multipart_upload` や期限切れセッションクリーンアップは未実装。未完了アップロードが溜まらないようホスト側で定期バッチを用意する。
41
+ - 認証/認可・レート制御はホストアプリ側で実装する。エンジンは `skip_forgery_protection`。
42
+
43
+ ## 7. トラブルシュートの要点
44
+ - Disk + stub で `upload_id` 重複 → 初期化でユニーク stub を設定済みか確認。
45
+ - 開発ダウンロードが 422 → セッションが completed か、結合パート欠損を確認 (`tmp/s3_direct_multipart_upload/<object_key>.parts/`).
46
+ - サービス設定不足 → `ActiveStorage service 's3_direct_multipart' is not configured` が出たら `config/storage.yml` を確認。
47
+
48
+ ## 8. Rubygems 公開手順(社内向け)
49
+ 1. バージョン更新: `lib/s3_direct_multipart_upload/version.rb` を適切に bump。
50
+ 2. 依存同期: `bundle install`。
51
+ 3. テスト: `bundle exec rspec`(必要なら `bundle exec rubocop` も)。
52
+ 4. ビルド: `gem build s3_direct_multipart_upload.gemspec`。
53
+ 5. 公開: `gem push s3_direct_multipart_upload-<version>.gem`(環境変数 `RUBYGEMS_API_KEY` を設定しておく)。
54
+ 6. 確認: `gem info s3_direct_multipart_upload` などで公開バージョンを確認し、ローカル `.gem` は不要なら削除。
55
+ 補足: GitHub リポジトリは private 運用のため、利用者には `.gem` に同梱される README/SPEC/OPERATIONS/AGENTS/OpenAPI を参照してもらう。
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # S3DirectMultipartUpload ![CI](https://github.com/logmi/s3_direct_multipart_upload/actions/workflows/rubocop.yml/badge.svg?branch=main)
2
+
3
+ Rails アプリに組み込み、S3 へのダイレクトマルチパートアップロード API を提供する mountable engine。認証・セッション管理はホストアプリケーション(`::ApplicationController` 由来の `before_action` 等)に委譲する。
4
+
5
+ ## 名称と対象範囲
6
+
7
+ - この gem は、AWS S3 の「Multipart upload」機能に対して、アップロードセッション管理と presigned URL の払い出しを行うためのエンジンです。設計は AWS ドキュメント “Multipart upload overview”(<https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html>)で説明されている
8
+ `CreateMultipartUpload` → `UploadPart` → `CompleteMultipartUpload` のフローに対応しています。
9
+ - 「direct」は、実データの転送がホストアプリケーションを経由せず、クライアントから S3 に対して直接 PUT される点(この gem は presigned URL だけを発行する)を指しています
10
+ - 実装上、`byte_size` が 5MB 未満の場合は README 記載の通りマルチパートではなく単一の `PutObject` 用 presigned URL を返しますが、本 gem の主対象は「S3 Multipart upload を利用するようなサイズのオブジェクトを、クライアントから直接 S3 に送る」ケースです
11
+
12
+ ## 開発方針
13
+ - 社内利用ツールを共有するために public repository としていますが、積極的な機能追加やサポートは想定していません。
14
+ - Issue/PR は歓迎しますが、対応はベストエフォートです。
15
+ - クリティカルな不具合やセキュリティ連絡は `develop@logmi.co.jp` までお願いします。
16
+
17
+ ## インストール(GitHub 公開リポジトリから利用する場合)
18
+ Gemfile に以下を追加し、`bundle install` を実行する。
19
+
20
+ ```ruby
21
+ gem 's3_direct_multipart_upload', github: 'logmi/s3_direct_multipart_upload'
22
+ ```
23
+
24
+ ## 導入手順
25
+ 1. ルーティングに `mount S3DirectMultipartUpload::Engine, at: '<お好みのパス>'` を追加する(`<mount_path>/s3_direct_multipart_uploads` 系のエンドポイントが有効になる)。
26
+ 2. `bin/rails s3_direct_multipart_upload:install:migrations` でマイグレーションをホストへコピーし、`bin/rails db:migrate` を実行する。
27
+
28
+ ## 設定
29
+ - バケットやリージョンなどの S3 接続情報は、ホストアプリの `config/storage/{env}.yml` に定義した `s3_direct_multipart` サービスを参照する。
30
+ - 例 (production.yml):
31
+ ```yml
32
+ s3_direct_multipart:
33
+ service: S3
34
+ bucket: your-bucket
35
+ region: ap-northeast-1
36
+ endpoint: https://s3.ap-northeast-1.amazonaws.com # 任意
37
+ force_path_style: false # 必要に応じて
38
+ ```
39
+ - 例 (development/test):
40
+ ```yml
41
+ s3_direct_multipart:
42
+ service: Disk
43
+ ```
44
+ - `s3_direct_multipart` が未定義の場合は起動時に例外となる。
45
+ - キープレフィックス: `uploads/s3_direct/yyyyMMdd/<session_uuid>` 形式で自動付与する。
46
+
47
+ ## セキュリティ・運用上の注意
48
+ - エンジン側では `skip_forgery_protection` を有効化しており、認証/認可やレート制御はホストアプリの `::ApplicationController` 由来の before_action 等に委譲する前提です。CSRF 対策や利用権限チェックが必要な場合は mount 先で必ずガードを追加してください。
49
+ - `UploadSession#expires_at` を設定するだけで、期限切れセッションのクリーンアップや `abort_multipart_upload` は未実装です。S3 上に未完了アップロードが蓄積しないよう、ホスト側で定期バッチを用意して後始末してください。
50
+
51
+ ## ファイルサイズとパート数の扱い
52
+ - `chunk_size` は 5MB 以上を推奨し、5GB 以下に収めること(S3 制約)。`byte_size` に対して期待パート数が 10,000 を超える場合はアップロードを拒否します。
53
+ - `byte_size` が 5MB 未満の場合はマルチパートを使わず単一パートとして扱い、1 件の presigned URL だけを返します(`chunk_size` は `byte_size` に自動調整)。
54
+
55
+ ## エンドポイント
56
+ - `POST <mount_path>/s3_direct_multipart_uploads`
57
+ - `POST <mount_path>/s3_direct_multipart_uploads/:upload_session_id/parts`
58
+ - `POST <mount_path>/s3_direct_multipart_uploads/:upload_session_id/complete`
59
+
60
+ レスポンス仕様は `doc/s3_direct_multipart_upload/api.yml` を参照。
61
+
62
+ ## ドキュメント導線
63
+ - 仕様 (SoT): `SPEC.md`
64
+ - 運用: `OPERATIONS.md`
65
+ - 意思決定: `ADR/`
66
+ - 実装ガイド: `AGENTS.md`
67
+ - サンプルアプリ: https://github.com/logmi/s3_direct_multipart_upload_example (公開デモ用の最小実装)
68
+
69
+ ## 配布形態
70
+ - Rubygems で公開(`gem 's3_direct_multipart_upload'`)。ソースリポジトリは現在 private なので、利用者は `.gem` に同梱されたドキュメント(README/SPEC/OPERATIONS/AGENTS と OpenAPI 定義)を参照してください。
71
+ - GitHub の issue/PR はベストエフォート対応。問い合わせは README 冒頭のメールアドレスまで。
72
+
73
+ ## 開発・貢献のはじめかた
74
+ - セットアップ: `bundle install` を実行(必要なら `gem update --system` で Rubygems を最新化)
75
+ - 開発用設定: `config/storage/development.yml` / `config/storage/test.yml` にある `s3_direct_multipart` (Disk) を利用する前提で動くため追加設定は不要
76
+ - テスト: `bundle exec rspec` を実行
77
+ - マイグレーション: ホスト側では `bin/rails s3_direct_multipart_upload:install:migrations` → `bin/rails db:migrate` を実行
78
+
79
+ ## Disk サービス利用時の開発用エンドポイント
80
+ - `service: Disk`(`stub_responses: true`)のまま presigned URL に対して PUT できるよう、開発/テスト環境限定で `PUT /s3_direct_multipart_upload/dev/storage/*path` を用意しています。
81
+ - 環境変数:
82
+ - `S3_DIRECT_DEV_ENDPOINT`: presigned URL に埋め込むベース URL。未設定時は `http://localhost:3000`。
83
+ - `S3_DIRECT_DEV_SECRET`: 署名生成に使うシークレット。未設定時は `dev-secret`。
84
+ - 使い方:
85
+ 1. `ActiveStorage` の `s3_direct_multipart` を `service: Disk` で設定する。
86
+ 2. `UploadSession#presigned_parts` が返す URL(クエリに `uploadId`, `partNumber`, `expires_at`, `signature` を含む)に対し、クライアントから `PUT` する。
87
+ 3. リクエストボディは `tmp/s3_direct_multipart_upload/<object_key>.parts/<partNumber>` に保存され、`ETag` ヘッダーが返る。
88
+ - 本番環境ではルート自体が無効になるため、開発専用の安全な代替として利用できます。
89
+ - Disk + stub_responses 利用時の upload_id 重複対策: development/test では `config/initializers/s3_direct_dev.rb` で `create_multipart_upload` をユニークな `upload_id`(`SecureRandom.uuid`)で stub しています。既存セッションが残っている場合でも一意制約に抵触しなくなります。
90
+ - ダウンロード確認: development/test では `UploadSession#complete!` 時にパートファイルを結合し、`tmp/s3_direct_multipart_upload/<object_key>` に単一ファイルを生成します。`GET /s3_direct_multipart_upload/dev/storage/:upload_session_id/download` にアクセスすると結合済みファイルを返します(completed セッションのみ許可、欠損時は 422)。
91
+
92
+ ## ライセンス
93
+ MIT
data/SPEC.md ADDED
@@ -0,0 +1,62 @@
1
+ # S3DirectMultipartUpload SPEC
2
+
3
+ ## 1. 目的と範囲
4
+ - Rails エンジンとして提供する S3 へのダイレクトマルチパートアップロード API。
5
+ - 主対象は 5MB 以上の大きなオブジェクトをクライアントから直接 S3 に送るケース。
6
+ - 仕様の真実の源泉(SoT)は本ファイル。運用は OPERATIONS.md を参照。
7
+
8
+ ## 2. 外部依存
9
+ - AWS S3(`create_multipart_upload`, `upload_part`, `complete_multipart_upload`)。
10
+ - ActiveStorage のサービス設定 `s3_direct_multipart` を利用(`config/storage.yml` または `config/storage/<env>.yml`)。
11
+
12
+ ## 3. API
13
+ ### 3.1 POST /s3_direct_multipart_uploads
14
+ - 入力: `filename`, `byte_size` (必須, >0), `content_type`, `chunk_size`(任意)。
15
+ - 処理: Multipart Upload を開始し `upload_id` と各パートの presigned URL を返す。
16
+ - バリデーション:
17
+ - `byte_size` > 0。
18
+ - `chunk_size` は 5MB 以上 5GB 以下。未指定時は 128MB。
19
+ - 期待パート数が 10,000 超なら 400(`part number limit exceeded`)。
20
+ - `byte_size` < 5MB の場合は単一パート扱いで `chunk_size = byte_size`。
21
+ - 出力: `upload_session` オブジェクト(`id`, `upload_id`, `bucket`, `key_prefix`, `chunk_size`, `part_limit`, `parts[]`)。
22
+
23
+ ### 3.2 API 詳細
24
+ - 本番 API の OpenAPI 定義は `doc/api.yml` を参照。
25
+ - 開発専用 API の OpenAPI 定義は `doc/api_dev.yml` を参照(development/test 限定)。
26
+ - 方針: 今後の API 変更時は OpenAPI ファイルを更新し、SPEC.md は参照先を維持して概要のみ記載する。
27
+ - 検証: `bundle exec rspec spec/doc/openapi_spec.rb` で `doc/*.yml` を OpenAPIParser によるパース検証。
28
+ - レスポンス契約チェック: RSpec の request spec で committee-rails を利用し、`doc/api.yml` / `doc/api_dev.yml` に対するスキーマ検証を行う(本番ミドルウェア適用はせず、テストで検知)。
29
+
30
+ ## 4. データモデル(主要フィールド)
31
+ - UploadSession: `session_id`(uniq), `upload_id`(uniq), `bucket`, `key_prefix`, `filename`, `byte_size`, `content_type`, `chunk_size`, `status (pending/uploading/completed/aborted)`, `metadata(json)`, `expires_at`, `created_at/updated_at`。
32
+ - UploadSessionPart: `upload_session_id`, `part_number`, `etag`, `uploaded_at`。
33
+ - キープレフィックス: `uploads/s3_direct/yyyyMMdd/<session_uuid>/<filename>`。
34
+
35
+ ## 5. バリデーションと制約
36
+ - 最小チャンク: 5MB。最大チャンク: 5GB。
37
+ - パート数上限: 10,000。超過時は 400/422。
38
+ - 単一パート条件: `byte_size` < 5MB → `chunk_size = byte_size` 必須。
39
+ - `s3_direct_multipart` サービス設定が存在しない場合は起動時に例外。
40
+
41
+ ## 6. 開発モード固有挙動
42
+ - `service: Disk` + `stub_responses: true` の場合、`create_multipart_upload` は毎回ユニーク `upload_id` を返す(stub)。
43
+ - 開発用 PUT で保存されたパートは complete 時に結合し、`tmp/s3_direct_multipart_upload/<object_key>` に単一ファイルを生成。
44
+ - ダウンロード API で結合済みファイルを返却(completed セッションのみ)。
45
+ - 署名用環境変数:
46
+ - `S3_DIRECT_DEV_SECRET`(デフォルト `dev-secret`)
47
+ - `S3_DIRECT_DEV_ENDPOINT`(デフォルト `http://localhost:3000`)
48
+ - S3 との挙動整合性: 開発用エンドポイントは S3 の PUT/UploadPart の表面挙動(200 + ETag、署名不正/期限切れは 403、存在しない/無効なリソースは 404/422)に寄せる。出典: [UploadPart](https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html), [PUT Object](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html), [署名エラー](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html#sigv4-query-string-auth-errors)。request spec でステータスとヘッダを検証し、OpenAPI (`doc/api_dev.yml`) と整合させている。
49
+
50
+ ## 7. エラーレスポンス方針(代表)
51
+ - 400 Bad Request: 無効な `byte_size`/`chunk_size`/パート数上限超過。
52
+ - 404 Not Found: セッション未存在、開発ルートの非 dev/test。
53
+ - 409 Conflict: パーツ不足で complete。
54
+ - 422 Unprocessable Content: 不正な `part_number`、完了/中断済み、開発ダウンロードで未完・欠損。
55
+
56
+ ## 8. 未実装・運用上の注意
57
+ - 期限切れセッションのクリーンアップや `abort_multipart_upload` は未実装。ホスト側で定期バッチを用意すること。
58
+ - 認証/認可・レート制御はホストアプリに委譲。エンジン側は `skip_forgery_protection`。
59
+
60
+ ## 9. 参考
61
+ - API スキーマ: `doc/api.yml`
62
+ - 設計メモ: `doc/specifications.md`
@@ -0,0 +1,4 @@
1
+ module S3DirectMultipartUpload
2
+ class ApplicationController < ::ApplicationController
3
+ end
4
+ end
@@ -0,0 +1,62 @@
1
+ require "digest"
2
+ require "fileutils"
3
+
4
+ module S3DirectMultipartUpload
5
+ module Dev
6
+ class StorageController < ::S3DirectMultipartUpload::ApplicationController
7
+ skip_forgery_protection
8
+
9
+ before_action :ensure_dev_environment!
10
+ before_action :verify_signature!
11
+
12
+ def upload
13
+ body = request.raw_post
14
+ path = storage_path
15
+
16
+ FileUtils.mkdir_p(path.dirname)
17
+ File.binwrite(path, body)
18
+
19
+ response.headers["ETag"] = %("#{Digest::MD5.hexdigest(body)}")
20
+ head :ok
21
+ end
22
+
23
+ private
24
+
25
+ def ensure_dev_environment!
26
+ return if Rails.env.development? || Rails.env.test?
27
+
28
+ head :not_found
29
+ end
30
+
31
+ def verify_signature!
32
+ expires_at = Time.at(params.require(:expires_at).to_i)
33
+ head :forbidden and return if Time.current >= expires_at
34
+
35
+ expected = UploadSession.dev_signature(
36
+ path: normalized_path_param,
37
+ expires_at:,
38
+ upload_id: params[:uploadId],
39
+ part_number: params[:partNumber]
40
+ )
41
+
42
+ head :forbidden unless ActiveSupport::SecurityUtils.secure_compare(expected, params.require(:signature))
43
+ rescue KeyError, ActionController::ParameterMissing
44
+ head :forbidden
45
+ end
46
+
47
+ def storage_path
48
+ upload_root = Rails.root.join("tmp/s3_direct_multipart_upload")
49
+ base = upload_root.join(normalized_path_param)
50
+
51
+ part_number = params[:partNumber].presence&.to_i
52
+ return base if part_number.blank?
53
+
54
+ base.dirname.join("#{base.basename}.parts", part_number.to_s)
55
+ end
56
+
57
+ def normalized_path_param
58
+ params.require(:path).sub(/\A\//, "")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,44 @@
1
+ module S3DirectMultipartUpload
2
+ module Dev
3
+ class StorageDownloadsController < ::S3DirectMultipartUpload::ApplicationController
4
+ skip_forgery_protection
5
+
6
+ before_action :ensure_dev_environment!
7
+ before_action :set_upload_session!
8
+
9
+ def show
10
+ return render json: { error: "upload is not completed" }, status: :unprocessable_content unless @upload_session.completed?
11
+
12
+ ensure_combined_file!
13
+ return render json: { error: "combined file is missing" }, status: :unprocessable_content unless dev_storage_path.exist?
14
+
15
+ send_file dev_storage_path, filename: @upload_session.filename, disposition: :inline, type: "application/octet-stream"
16
+ rescue RuntimeError => e
17
+ render json: { error: e.message }, status: :unprocessable_content
18
+ end
19
+
20
+ private
21
+
22
+ def ensure_dev_environment!
23
+ return if Rails.env.development? || Rails.env.test?
24
+
25
+ head :not_found
26
+ end
27
+
28
+ def set_upload_session!
29
+ @upload_session = ::S3DirectMultipartUpload::UploadSession.find_by(session_id: params[:upload_session_id])
30
+ render json: { error: "not found" }, status: :not_found unless @upload_session
31
+ end
32
+
33
+ def ensure_combined_file!
34
+ return if S3DirectMultipartUpload::Dev::Storage.combined_file_exist?(@upload_session)
35
+
36
+ S3DirectMultipartUpload::Dev::Storage.combine_parts(@upload_session)
37
+ end
38
+
39
+ def dev_storage_path
40
+ S3DirectMultipartUpload::Dev::Storage.combined_path(@upload_session)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ module S3DirectMultipartUpload
2
+ class UploadCompletionsController < ::S3DirectMultipartUpload::ApplicationController
3
+ skip_forgery_protection
4
+ before_action :set_upload_session
5
+
6
+ def create
7
+ @upload_session.complete!(checksum: complete_params[:checksum])
8
+ render json: {
9
+ upload_session: {
10
+ id: @upload_session.session_id,
11
+ status: @upload_session.status,
12
+ upload_id: @upload_session.upload_id
13
+ }
14
+ }
15
+ rescue ActiveRecord::RecordNotFound
16
+ render json: { error: "not found" }, status: :not_found
17
+ rescue RuntimeError => e
18
+ render json: { error: e.message }, status: :conflict
19
+ end
20
+
21
+ private
22
+
23
+ def complete_params
24
+ params.fetch(:upload, {}).permit(:checksum)
25
+ end
26
+
27
+ def set_upload_session
28
+ @upload_session = ::S3DirectMultipartUpload::UploadSession.find_by(session_id: params[:upload_session_id])
29
+ render json: { error: "not found" }, status: :not_found unless @upload_session
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,29 @@
1
+ module S3DirectMultipartUpload
2
+ class UploadPartsController < ::S3DirectMultipartUpload::ApplicationController
3
+ skip_forgery_protection
4
+ before_action :set_upload_session
5
+
6
+ def create
7
+ @upload_session.report_part!(
8
+ part_number: part_params.fetch(:part_number).to_i,
9
+ etag: part_params.fetch(:etag)
10
+ )
11
+ head :accepted
12
+ rescue ActiveRecord::RecordNotFound
13
+ render json: { error: "not found" }, status: :not_found
14
+ rescue ArgumentError, ActiveRecord::RecordInvalid => e
15
+ render json: { error: e.message }, status: :unprocessable_content
16
+ end
17
+
18
+ private
19
+
20
+ def part_params
21
+ params.require(:part).permit(:part_number, :etag)
22
+ end
23
+
24
+ def set_upload_session
25
+ @upload_session = ::S3DirectMultipartUpload::UploadSession.find_by(session_id: params[:upload_session_id])
26
+ render json: { error: "not found" }, status: :not_found unless @upload_session
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ module S3DirectMultipartUpload
2
+ class UploadSessionsController < ::S3DirectMultipartUpload::ApplicationController
3
+ skip_forgery_protection
4
+
5
+ def create
6
+ upload_session = ::S3DirectMultipartUpload::UploadSession.start!(
7
+ filename: upload_params.fetch(:filename),
8
+ byte_size: upload_params.fetch(:byte_size).to_i,
9
+ content_type: upload_params[:content_type],
10
+ chunk_size: upload_params[:chunk_size].presence&.to_i,
11
+ metadata: { "initiator_id" => session[:identity_id] }.compact
12
+ )
13
+
14
+ render json: serialize_session(upload_session), status: :created
15
+ rescue ArgumentError => e
16
+ render json: { error: e.message }, status: :bad_request
17
+ end
18
+
19
+ private
20
+
21
+ def upload_params
22
+ params.require(:upload).permit(:filename, :byte_size, :content_type, :chunk_size)
23
+ end
24
+
25
+ def serialize_session(session)
26
+ {
27
+ upload_session: {
28
+ id: session.session_id,
29
+ upload_id: session.upload_id,
30
+ bucket: session.bucket,
31
+ key_prefix: session.key_prefix,
32
+ chunk_size: session.chunk_size,
33
+ part_limit: session.part_limit,
34
+ parts: session.presigned_parts(part_numbers: suggested_part_numbers(session))
35
+ }
36
+ }
37
+ end
38
+
39
+ def suggested_part_numbers(session)
40
+ expected_parts = session.expected_parts
41
+ expected_parts = [ expected_parts, 1 ].max
42
+ expected_parts = [ expected_parts, session.part_limit ].min
43
+ Array.new(expected_parts) { |i| i + 1 }
44
+ end
45
+ end
46
+ end