-
Notifications
You must be signed in to change notification settings - Fork 413
refactor: introduce FileIO extension to abstract file transfer operations #297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0a84f10
2ecd703
cd306f6
18c43a7
d372c0d
f39f91b
3e48f83
71ea3f4
9f7aa78
7b6f14d
1cf4b58
bf5d6bd
a4e0fcf
d9dc585
cb15a09
8867dca
1c30e74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package fileio | ||
|
|
||
| import "sync" | ||
|
|
||
| var ( | ||
| mu sync.Mutex | ||
| provider Provider | ||
| ) | ||
|
|
||
| // Register registers a FileIO Provider. | ||
| // Later registrations override earlier ones. | ||
| // Typically called from init() via blank import. | ||
| func Register(p Provider) { | ||
| mu.Lock() | ||
| defer mu.Unlock() | ||
| provider = p | ||
| } | ||
|
|
||
| // GetProvider returns the currently registered Provider. | ||
| // Returns nil if no provider has been registered. | ||
| func GetProvider() Provider { | ||
| mu.Lock() | ||
| defer mu.Unlock() | ||
| return provider | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package fileio | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "os" | ||
| ) | ||
|
|
||
| // Provider creates FileIO instances. | ||
| // Follows the same API style as extension/credential.Provider. | ||
| type Provider interface { | ||
| Name() string | ||
| ResolveFileIO(ctx context.Context) FileIO | ||
| } | ||
|
|
||
| // FileIO abstracts file transfer operations for CLI commands. | ||
| // The default implementation operates on the local filesystem with | ||
| // path validation, directory creation, and atomic writes. | ||
| // Inject a custom implementation via Factory.FileIOProvider to replace | ||
| // file transfer behavior (e.g. streaming in server mode). | ||
| type FileIO interface { | ||
| // Open opens a file for reading (upload, attachment, template scenarios). | ||
| // The default implementation validates the path via SafeInputPath. | ||
| Open(name string) (File, error) | ||
|
|
||
| // Stat returns file metadata (size validation, existence checks). | ||
| // The default implementation validates the path via SafeInputPath. | ||
| // Use os.IsNotExist(err) to distinguish "file not found" from "invalid path". | ||
| Stat(name string) (os.FileInfo, error) | ||
|
|
||
| // ResolvePath returns the validated, absolute path for the given output path. | ||
| // The default implementation delegates to SafeOutputPath. | ||
| // Use this to obtain the canonical saved path for user-facing output. | ||
| ResolvePath(path string) (string, error) | ||
|
|
||
| // Save writes content to the target path and returns a SaveResult. | ||
| // The default implementation validates via SafeOutputPath, creates | ||
| // parent directories, and writes atomically. | ||
| Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error) | ||
| } | ||
|
|
||
| // File is the interface returned by FileIO.Open. | ||
| // It covers the subset of *os.File methods actually used by CLI commands. | ||
| // *os.File satisfies this interface without adaptation. | ||
| type File interface { | ||
| io.Reader | ||
| io.ReaderAt | ||
| io.Closer | ||
| Stat() (os.FileInfo, error) | ||
| } | ||
|
|
||
| // SaveResult holds the outcome of a Save operation. | ||
| type SaveResult interface { | ||
| Size() int64 // actual bytes written | ||
| } | ||
|
|
||
| // SaveOptions carries metadata for Save. | ||
| // The default (local) implementation ignores these fields; | ||
| // server-mode implementations use them to construct streaming response frames. | ||
| type SaveOptions struct { | ||
| ContentType string // MIME type | ||
| ContentLength int64 // content length; -1 if unknown | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,15 +9,13 @@ import ( | |
| "fmt" | ||
| "io" | ||
| "mime" | ||
| "path/filepath" | ||
| "strings" | ||
|
|
||
| larkcore "github.com/larksuite/oapi-sdk-go/v3/core" | ||
|
|
||
| "github.com/larksuite/cli/extension/fileio" | ||
| "github.com/larksuite/cli/internal/output" | ||
| "github.com/larksuite/cli/internal/util" | ||
| "github.com/larksuite/cli/internal/validate" | ||
| "github.com/larksuite/cli/internal/vfs" | ||
| ) | ||
|
|
||
| // ── Response routing ── | ||
|
|
@@ -29,6 +27,7 @@ type ResponseOptions struct { | |
| JqExpr string // if set, apply jq filter instead of Format | ||
| Out io.Writer // stdout | ||
| ErrOut io.Writer // stderr | ||
| FileIO fileio.FileIO // file transfer abstraction; nil falls back to direct os calls | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid a nil
🩹 Minimal guard func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
+ if fio == nil {
+ return nil, fmt.Errorf("file I/O not configured")
+ }
result, err := fio.Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: int64(len(resp.RawBody)),
}, bytes.NewReader(resp.RawBody))Also applies to: 63-63, 77-81, 122-126 🤖 Prompt for AI Agents |
||
| // CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse. | ||
| CheckError func(interface{}) error | ||
| } | ||
|
|
@@ -61,7 +60,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { | |
| return apiErr | ||
| } | ||
| if opts.OutputPath != "" { | ||
| return saveAndPrint(resp, opts.OutputPath, opts.Out) | ||
| return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) | ||
| } | ||
| if opts.JqExpr != "" { | ||
| return output.JqFilter(opts.Out, result, opts.JqExpr) | ||
|
|
@@ -75,11 +74,11 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { | |
| return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct) | ||
| } | ||
| if opts.OutputPath != "" { | ||
| return saveAndPrint(resp, opts.OutputPath, opts.Out) | ||
| return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) | ||
| } | ||
|
|
||
| // No --output: auto-save with derived filename. | ||
| meta, err := SaveResponse(resp, ResolveFilename(resp)) | ||
| meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp)) | ||
| if err != nil { | ||
| return output.Errorf(output.ExitInternal, "file_error", "%s", err) | ||
|
Comment on lines
82
to
83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve validation failures from
Also applies to: 92-93, 123-129 🤖 Prompt for AI Agents |
||
| } | ||
|
|
@@ -88,8 +87,8 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { | |
| return nil | ||
| } | ||
|
|
||
| func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error { | ||
| meta, err := SaveResponse(resp, path) | ||
| func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error { | ||
| meta, err := SaveResponse(fio, resp, path) | ||
| if err != nil { | ||
| return output.Errorf(output.ExitInternal, "file_error", "%s", err) | ||
| } | ||
|
|
@@ -119,23 +118,23 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) { | |
| // ── File saving ── | ||
|
|
||
| // SaveResponse writes an API response body to the given outputPath and returns metadata. | ||
| func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { | ||
| safePath, err := validate.SafeOutputPath(outputPath) | ||
| // It delegates to FileIO.Save for path validation and atomic write; fio must not be nil. | ||
| func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { | ||
| result, err := fio.Save(outputPath, fileio.SaveOptions{ | ||
| ContentType: resp.Header.Get("Content-Type"), | ||
| ContentLength: int64(len(resp.RawBody)), | ||
| }, bytes.NewReader(resp.RawBody)) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("unsafe output path: %s", err) | ||
| } | ||
|
|
||
| if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { | ||
| return nil, fmt.Errorf("create directory: %s", err) | ||
| } | ||
|
|
||
| if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil { | ||
| return nil, fmt.Errorf("cannot write file: %s", err) | ||
| } | ||
|
|
||
| resolvedPath, _ := fio.ResolvePath(outputPath) | ||
| if resolvedPath == "" { | ||
| resolvedPath = outputPath | ||
| } | ||
| return map[string]interface{}{ | ||
| "saved_path": safePath, | ||
| "size_bytes": len(resp.RawBody), | ||
| "saved_path": resolvedPath, | ||
| "size_bytes": result.Size(), | ||
| "content_type": resp.Header.Get("Content-Type"), | ||
| }, nil | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rule denies
internal/vfsimports from**/shortcuts/**, but two non-test production files that are not in this PR still import it:shortcuts/event/pipeline.go—vfs.MkdirAll(line 20)shortcuts/mail/mail_watch.go—vfs.UserHomeDirandvfs.MkdirAll(line 28)The current
FileIOinterface only exposesOpen,Stat, andSave, so these callers cannot be migrated without first extending the interface. Runninggolangci-linton the full codebase will produce new depguard errors on these files.