Files
larksuite__cli/shortcuts/common/drive_media_upload.go
zhoujunteng-max 5b734238d7 feat: report upload file events (#2093)
* feat: report upload file events

* test(drive): skip import workflow without tenant token

* docs: document upload report helpers

* docs: improve function documentation coverage

* docs: complete incremental function documentation

* docs: complete function documentation coverage

* fix: report every upload file event

* refactor: move file event reporting to internal package

* fix: harden upload file event reporting

* fix: omit upload mode from file event reports

* test: fix workbook import dry-run token assertion
2026-08-10 19:12:38 +08:00

297 lines
10 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/fileevent"
)
const MaxDriveMediaUploadSinglePartSize int64 = 20 * 1024 * 1024 // 20MB
const (
driveMediaUploadAllAction = "upload media failed"
driveMediaUploadPartAction = "upload media part failed"
driveMediaUploadFinishAction = "upload media finish failed"
)
const (
driveMediaUploadAllPath = "/open-apis/drive/v1/medias/upload_all"
driveMediaUploadPreparePath = "/open-apis/drive/v1/medias/upload_prepare"
driveMediaUploadPartPath = "/open-apis/drive/v1/medias/upload_part"
driveMediaUploadFinishPath = "/open-apis/drive/v1/medias/upload_finish"
)
type DriveMediaMultipartUploadSession struct {
UploadID string
BlockSize int64
BlockNum int
}
type DriveMediaUploadAllConfig struct {
FilePath string
FileName string
FileSize int64
ParentType string
ParentNode *string
Extra string
// Reader, when non-nil, is used as the upload source instead of opening
// FilePath. Callers must set FileName and FileSize explicitly. The reader
// is NOT closed by UploadDriveMediaAllTyped; the caller owns its lifetime.
// Used by the clipboard path in docs +media-insert.
Reader io.Reader
}
type DriveMediaMultipartUploadConfig struct {
FilePath string
FileName string
FileSize int64
ParentType string
ParentNode string
Extra string
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
Reader io.Reader
}
// UploadDriveMediaAllTyped uploads a file in a single request: file-open
// failures surface as typed validation errors, transport failures as typed
// network errors, and API failures are classified via ClassifyAPIResponse so
// subtype / code / log_id survive on the error.
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
fileReader = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatErrorTyped(err)
}
defer f.Close()
fileReader = f
}
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
fd.AddField("parent_type", cfg.ParentType)
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
if cfg.ParentNode != nil {
fd.AddField("parent_node", *cfg.ParentNode)
}
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
fd.AddFile("file", fileReader)
meta := fileevent.UploadMeta{
APIPath: driveMediaUploadAllPath,
ResourceType: "media",
ParentType: cfg.ParentType,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: driveMediaUploadAllPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", fileevent.ReportUploadError(runtime, prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction), meta)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", fileevent.ReportUploadError(runtime, prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction), meta)
}
fileToken, err := extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
if err != nil {
return "", fileevent.ReportUploadError(runtime, err, meta)
}
meta.FileToken = fileToken
fileevent.ReportUpload(runtime, meta)
return fileToken, nil
}
// UploadDriveMediaMultipartTyped uploads a file in server-planned chunks:
// prepare/finish failures come back typed from CallAPITyped, malformed session
// plans surface as invalid-response internal errors, and per-part
// transport/API failures are classified the same way as
// UploadDriveMediaAllTyped.
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
// string instead of relying on field omission like upload_all does.
prepareBody := map[string]interface{}{
"file_name": cfg.FileName,
"parent_type": cfg.ParentType,
"parent_node": cfg.ParentNode,
"size": cfg.FileSize,
}
if cfg.Extra != "" {
prepareBody["extra"] = cfg.Extra
}
meta := fileevent.UploadMeta{
APIPath: driveMediaUploadPreparePath,
ResourceType: "media",
ParentType: cfg.ParentType,
}
data, err := runtime.CallAPITyped("POST", driveMediaUploadPreparePath, nil, prepareBody)
if err != nil {
return "", fileevent.ReportUploadError(runtime, err, meta)
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", fileevent.ReportUploadError(runtime, err, meta)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
meta.APIPath = driveMediaUploadPartPath
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", fileevent.ReportUploadError(runtime, err, meta)
}
meta.APIPath = driveMediaUploadFinishPath
fileToken, err := finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
if err != nil {
return "", fileevent.ReportUploadError(runtime, err, meta)
}
meta.FileToken = fileToken
fileevent.ReportUpload(runtime, meta)
return fileToken, nil
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
// message so callers see which upload step failed. Non-typed errors are
// returned unchanged.
func prefixDriveMediaUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
}
return err
}
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
// session plan, reporting a malformed plan as a typed invalid-response
// internal error.
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
// The backend chooses both chunk size and chunk count. Validate them once so
// the streaming loop can follow the returned plan without re-checking shape.
session := DriveMediaMultipartUploadSession{
UploadID: GetString(data, "upload_id"),
BlockSize: int64(GetFloat(data, "block_size")),
BlockNum: int(GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
}
return session, nil
}
// extractDriveMediaUploadFileTokenTyped reads the file_token from a successful
// upload response, reporting a missing file_token as a typed invalid-response
// internal error.
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
}
return fileToken, nil
}
// uploadDriveMediaMultipartPartsTyped streams the file in server-planned
// chunks, with typed errors for file-open, file-read, and per-part upload
// failures.
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
var r io.Reader
if cfg.Reader != nil {
r = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return WrapInputStatErrorTyped(err)
}
defer f.Close()
r = f
}
maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := cfg.FileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
if readErr != nil {
return WrapInputStatErrorTyped(readErr)
}
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
remaining -= int64(n)
}
return nil
}
// uploadDriveMediaMultipartPartTyped uploads one indexed block of a multipart media upload.
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: driveMediaUploadPartPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
}
return nil
}
// finishDriveMediaMultipartUploadTyped completes a multipart media upload and returns its file token.
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", driveMediaUploadFinishPath, nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
}