From 8201c2a4bdd36e5b0fde642aaadbc213013b6fca Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 8 Apr 2026 08:17:24 -0400 Subject: [PATCH] Add JSON API support for user data exports (#2786) --- .../users/data_exports_controller.rb | 16 +++- .../users/data_exports/show.json.jbuilder | 6 ++ docs/api/README.md | 1 + docs/api/sections/exports.md | 77 +++++++++++++++++ .../users/data_exports_controller_test.rb | 86 +++++++++++++++++++ 5 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 app/views/users/data_exports/show.json.jbuilder create mode 100644 docs/api/sections/exports.md diff --git a/app/controllers/users/data_exports_controller.rb b/app/controllers/users/data_exports_controller.rb index 60df1e326..8a3239b8f 100644 --- a/app/controllers/users/data_exports_controller.rb +++ b/app/controllers/users/data_exports_controller.rb @@ -7,11 +7,20 @@ class Users::DataExportsController < ApplicationController CURRENT_EXPORT_LIMIT = 10 def show + respond_to do |format| + format.html + format.json { @export ? render(:show) : head(:not_found) } + end end def create - @user.data_exports.create!(account: Current.account).build_later - redirect_to @user, notice: "Export started. You'll receive an email when it's ready." + @export = @user.data_exports.create!(account: Current.account) + @export.build_later + + respond_to do |format| + format.html { redirect_to @user, notice: "Export started. You'll receive an email when it's ready." } + format.json { render :show, status: :created } + end end private @@ -28,6 +37,7 @@ class Users::DataExportsController < ApplicationController end def set_export - @export = @user.data_exports.completed.find_by(id: params[:id]) + scope = request.format.json? ? @user.data_exports : @user.data_exports.completed + @export = scope.find_by(id: params[:id]) end end diff --git a/app/views/users/data_exports/show.json.jbuilder b/app/views/users/data_exports/show.json.jbuilder new file mode 100644 index 000000000..2bd9e3d60 --- /dev/null +++ b/app/views/users/data_exports/show.json.jbuilder @@ -0,0 +1,6 @@ +json.(@export, :id, :status) +json.created_at @export.created_at.utc + +if @export.completed? && @export.file.attached? + json.download_url rails_blob_url(@export.file, disposition: "attachment") +end diff --git a/docs/api/README.md b/docs/api/README.md index 92d5fe2c2..6c9e8b8d5 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -19,6 +19,7 @@ a bot to perform various actions for you. - [Users](sections/users.md) - [Notifications](sections/notifications.md) - [Rich Text](sections/rich_text.md) +- [Exports](sections/exports.md) - [Webhooks](sections/webhooks.md) ## Authentication diff --git a/docs/api/sections/exports.md b/docs/api/sections/exports.md new file mode 100644 index 000000000..e6d214676 --- /dev/null +++ b/docs/api/sections/exports.md @@ -0,0 +1,77 @@ +# Exports + +Exports are asynchronous jobs. Start an export with `POST`, then poll the export resource with `GET` until it reaches `completed`. When the export file is ready, the response includes a temporary `download_url`. + +Possible export statuses are: +- `pending` +- `processing` +- `completed` +- `failed` + +Completed export files expire after 24 hours. When that happens, request a new export. + +## `POST /account/exports` + +Starts an account export for the current account. Only account admins and owners can create account exports. + +__Response:__ + +Returns `201 Created` with the export object: + +```json +{ + "id": "03f8huu0sog76g3s97596abcd", + "status": "pending", + "created_at": "2026-04-02T12:34:56Z" +} +``` + +## `GET /account/exports/:id` + +Returns the status of an account export created by the current user. + +__Response:__ + +```json +{ + "id": "03f8huu0sog76g3s97596abcd", + "status": "completed", + "created_at": "2026-04-02T12:34:56Z", + "download_url": "https://app.fizzy.do/rails/active_storage/blobs/redirect/.../fizzy-account-export.zip" +} +``` + +The `download_url` field is only present when the export is completed and the export file is still available. Download requests still require normal authenticated access as the export owner. + +## `POST /:account_slug/users/:user_id/data_exports` + +Starts a personal data export for the current user. You can only create exports for your own user record. + +__Response:__ + +Returns `201 Created` with the export object: + +```json +{ + "id": "03f8huu0sog76g3s97596wxyz", + "status": "pending", + "created_at": "2026-04-02T12:34:56Z" +} +``` + +## `GET /:account_slug/users/:user_id/data_exports/:id` + +Returns the status of one of your personal data exports. + +__Response:__ + +```json +{ + "id": "03f8huu0sog76g3s97596wxyz", + "status": "completed", + "created_at": "2026-04-02T12:34:56Z", + "download_url": "https://app.fizzy.do/rails/active_storage/blobs/redirect/.../fizzy-user-data-export.zip" +} +``` + +The `download_url` field is only present when the export is completed and the export file is still available. Download requests still require normal authenticated access as the export owner. diff --git a/test/controllers/users/data_exports_controller_test.rb b/test/controllers/users/data_exports_controller_test.rb index c48254705..4007c6eef 100644 --- a/test/controllers/users/data_exports_controller_test.rb +++ b/test/controllers/users/data_exports_controller_test.rb @@ -67,6 +67,62 @@ class Users::DataExportsControllerTest < ActionDispatch::IntegrationTest assert_select "h2", "Download Expired" end + test "create as JSON" do + assert_difference -> { User::DataExport.count }, 1 do + assert_enqueued_with(job: DataExportJob) do + post user_data_exports_path(@user), as: :json + end + end + + assert_response :created + body = @response.parsed_body + assert body["id"].present? + assert_equal "pending", body["status"] + assert_nil body["download_url"] + end + + test "show as JSON with completed export" do + export = @user.data_exports.create!(account: Current.account) + export.build + + get user_data_export_path(@user, export), as: :json + assert_response :success + + body = @response.parsed_body + assert_equal export.id, body["id"] + assert_equal "completed", body["status"] + assert body["download_url"].present? + end + + test "show as JSON with pending export" do + export = @user.data_exports.create!(account: Current.account) + + get user_data_export_path(@user, export), as: :json + assert_response :success + + body = @response.parsed_body + assert_equal export.id, body["id"] + assert_equal "pending", body["status"] + assert_nil body["download_url"] + end + + test "show as JSON with failed export" do + export = @user.data_exports.create!(account: Current.account, status: :failed) + + get user_data_export_path(@user, export), as: :json + assert_response :success + + body = @response.parsed_body + assert_equal export.id, body["id"] + assert_equal "failed", body["status"] + assert_nil body["download_url"] + end + + test "show as JSON with missing export" do + get user_data_export_path(@user, "nonexistent"), as: :json + assert_response :not_found + end + test "create is forbidden for other users" do other_user = users(:kevin) @@ -75,6 +131,14 @@ class Users::DataExportsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + test "create as JSON is forbidden for other users" do + other_user = users(:kevin) + + post user_data_exports_path(other_user), as: :json + + assert_response :forbidden + end + test "show is forbidden for other users" do other_user = users(:kevin) export = other_user.data_exports.create!(account: Current.account) @@ -84,4 +148,26 @@ class Users::DataExportsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end + + test "show as JSON is forbidden for other users" do + other_user = users(:kevin) + export = other_user.data_exports.create!(account: Current.account) + export.build + + get user_data_export_path(other_user, export), as: :json + + assert_response :forbidden + end + + test "create as JSON rejects request when current export limit is reached" do + Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do + @user.data_exports.create!(account: Current.account) + end + + assert_no_difference -> { User::DataExport.count } do + post user_data_exports_path(@user), as: :json + end + + assert_response :too_many_requests + end end