Skip to content

Commit 6b77524

Browse files
authored
Fix processing enormous json (#100)
* wip * wiop * wip * wip * wip * wip * wip * wip * wip * wip
1 parent 730f031 commit 6b77524

6 files changed

Lines changed: 193 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,4 @@ cli/bin/yapi
5757
specs
5858
.specify
5959
.claude/commands/specify.*
60+
.idea

cli/cmd/yapi/run.go

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type runContext struct {
2727
envName string // Target environment from yapi.config.yml
2828
jsonOutput bool // If true, output structured JSON instead of formatted output
2929
strictEnv bool // If true, error on missing env files and disable OS env fallback
30+
verbose bool // If true, show verbose output (request details, timing, headers)
3031
}
3132

3233
func (app *rootCommand) runInteractiveE(cmd *cobra.Command, args []string) error {
@@ -47,7 +48,8 @@ func (app *rootCommand) runE(cmd *cobra.Command, args []string) error {
4748
envName, _ := cmd.Flags().GetString("env")
4849
jsonOutput, _ := cmd.Flags().GetBool("json")
4950
strictEnv, _ := cmd.Flags().GetBool("strict-env")
50-
return app.runConfigPathWithOptionsE(path, envName, jsonOutput, strictEnv)
51+
verbose, _ := cmd.Flags().GetBool("verbose")
52+
return app.runConfigPathWithOptionsE(path, envName, jsonOutput, strictEnv, verbose)
5153
}
5254

5355
func (app *rootCommand) watchE(cmd *cobra.Command, args []string) error {
@@ -128,37 +130,77 @@ func printWatchHeader(path string) {
128130
fmt.Printf("%s\n\n", color.Dim("["+time.Now().Format("15:04:05")+"]"))
129131
}
130132

133+
// OutputSavedError is returned when output is too large for terminal and was saved to file.
134+
type OutputSavedError struct {
135+
Path string
136+
}
137+
138+
func (e *OutputSavedError) Error() string {
139+
return fmt.Sprintf("output saved to %s (too large for terminal)\nview with: cat %s | jq", e.Path, e.Path)
140+
}
141+
142+
// printResultOptions configures printResult behavior.
143+
type printResultOptions struct {
144+
skipMeta bool // Don't print URL/Time/Size (already shown in verbose mode)
145+
}
146+
131147
// printResult outputs a single result with optional expectation.
132-
func (app *rootCommand) printResult(result *runner.Result, expectRes *runner.ExpectationResult) {
148+
// configPath is used for generating auto-save filenames when output is too large.
149+
// Returns OutputSavedError if output was saved to file instead of printed.
150+
func (app *rootCommand) printResult(result *runner.Result, expectRes *runner.ExpectationResult, configPath string, opts printResultOptions) error {
151+
var savedPath string
133152
if result != nil {
134153
// Check if stdout is a TTY (terminal)
135154
isTTY := isTerminal(os.Stdout)
136155

137156
// Check if content is binary
138157
isBinary := utils.IsBinaryContent(result.Body)
139158

140-
// Skip dumping binary output unless explicitly requested with --binary-output
141-
if isBinary && !app.binaryOutput {
159+
switch {
160+
case isBinary && !app.binaryOutput:
161+
// Skip dumping binary output unless explicitly requested with --binary-output
142162
if isTTY {
143163
fmt.Fprintf(os.Stderr, "\n%s\n", color.Yellow("Binary content detected. Output hidden to prevent terminal corruption."))
144164
fmt.Fprintf(os.Stderr, "%s\n", color.Dim("To display binary output, use --binary-output flag or pipe to a file."))
145165
}
146166
// In non-TTY (CI/piped), silently skip binary output
147-
} else {
148-
body := strings.TrimRight(output.Highlight(result.Body, result.ContentType, app.noColor), "\n\r")
149-
fmt.Println(body)
167+
case result.OutputFile != "":
168+
// Output was already saved via output_file config - don't write again
169+
if len(result.Body) > maxOutputSize {
170+
savedPath = result.OutputFile
171+
} else {
172+
// Small enough to print, but also saved to file
173+
body := output.Highlight(result.Body, result.ContentType, app.noColor)
174+
fmt.Println(strings.TrimRight(body, "\n\r"))
175+
}
176+
default:
177+
// No output_file specified - render normally (may auto-save if large)
178+
body := result.Body
179+
if len(body) <= maxOutputSize {
180+
body = output.Highlight(body, result.ContentType, app.noColor)
181+
}
182+
outputResult := renderOutput(body, configPath)
183+
savedPath = outputResult.SavedPath
150184
}
151185

152-
printResultMeta(result)
186+
if !opts.skipMeta {
187+
printResultMeta(result)
188+
}
153189
}
154190
if expectRes != nil {
155191
printExpectationResult(expectRes)
156192
}
193+
if savedPath != "" {
194+
return &OutputSavedError{Path: savedPath}
195+
}
196+
return nil
157197
}
158198

159199
// executeRunE is the unified execution pipeline for both Run and Watch modes.
160200
// Returns error for middleware to capture.
161201
func (app *rootCommand) executeRunE(ctx runContext) error {
202+
log := NewLogger(ctx.verbose)
203+
162204
opts := runner.Options{
163205
URLOverride: app.urlOverride,
164206
NoColor: app.noColor,
@@ -168,6 +210,8 @@ func (app *rootCommand) executeRunE(ctx runContext) error {
168210
StrictEnv: ctx.strictEnv,
169211
}
170212

213+
log.Verbose("Loading config: %s", ctx.path)
214+
171215
// Load project and environment configuration
172216
projEnv, err := loadProjectAndEnv(ctx.path, ctx.envName, true)
173217
if err != nil {
@@ -180,15 +224,25 @@ func (app *rootCommand) executeRunE(ctx runContext) error {
180224

181225
// Apply project settings if found
182226
if projEnv != nil {
227+
log.Verbose("Project: %s", projEnv.projectRoot)
183228
opts.ProjectRoot = projEnv.projectRoot
184229
if projEnv.envVars != nil {
230+
log.Verbose("Environment: %s (%d vars)", projEnv.envName, len(projEnv.envVars))
185231
opts.EnvOverrides = projEnv.envVars
186232
opts.ProjectEnv = projEnv.envName
187233
}
188234
}
189235

236+
log.Verbose("Sending request...")
190237
runRes := app.engine.RunConfig(context.Background(), ctx.path, opts)
191238

239+
// Log response details if available
240+
if runRes.Result != nil {
241+
log.Response(runRes.Result.StatusCode, runRes.Result.Headers, runRes.Result.Duration, runRes.Result.BodyBytes)
242+
} else if runRes.Error != nil {
243+
log.Verbose("Request failed: %v", runRes.Error)
244+
}
245+
192246
// Handle validation/parse errors first
193247
if runRes.Error != nil && runRes.Analysis == nil {
194248
if ctx.strict || ctx.returnErrors {
@@ -236,7 +290,9 @@ func (app *rootCommand) executeRunE(ctx runContext) error {
236290
})
237291
}
238292

239-
app.printResult(runRes.Result, runRes.ExpectRes)
293+
if err := app.printResult(runRes.Result, runRes.ExpectRes, ctx.path, printResultOptions{skipMeta: ctx.verbose}); err != nil {
294+
return err
295+
}
240296

241297
if runRes.Error != nil {
242298
if ctx.strict || ctx.returnErrors {
@@ -261,14 +317,17 @@ func (app *rootCommand) executeChain(ctx runContext, runRes *core.RunConfigResul
261317
}
262318

263319
// Print results from all completed steps (even if chain failed)
320+
var outputSavedErr error
264321
if chainResult != nil {
265322
for i, stepResult := range chainResult.Results {
266323
fmt.Fprintf(os.Stderr, "\n--- Step %d: %s ---\n", i+1, chainResult.StepNames[i])
267324
var expectRes *runner.ExpectationResult
268325
if i < len(chainResult.ExpectationResults) {
269326
expectRes = chainResult.ExpectationResults[i]
270327
}
271-
app.printResult(stepResult, expectRes)
328+
if err := app.printResult(stepResult, expectRes, ctx.path, printResultOptions{}); err != nil {
329+
outputSavedErr = err
330+
}
272331
}
273332
}
274333

@@ -283,7 +342,7 @@ func (app *rootCommand) executeChain(ctx runContext, runRes *core.RunConfigResul
283342
fmt.Fprintln(os.Stderr, "\nChain completed successfully.")
284343
out, noColor := app.io(ctx.strict)
285344
validation.PrintWarnings(runRes.Analysis, out, noColor)
286-
return nil
345+
return outputSavedErr
287346
}
288347

289348
// runConfigPathE runs a config file in strict mode (returns error)
@@ -299,8 +358,8 @@ func (app *rootCommand) runConfigPathWithEnvAndJSONE(path string, envName string
299358
}
300359

301360
// runConfigPathWithOptionsE runs a config file with all options
302-
func (app *rootCommand) runConfigPathWithOptionsE(path string, envName string, jsonOutput bool, strictEnv bool) error {
303-
return app.executeRunE(runContext{path: path, strict: true, envName: envName, jsonOutput: jsonOutput, strictEnv: strictEnv})
361+
func (app *rootCommand) runConfigPathWithOptionsE(path string, envName string, jsonOutput bool, strictEnv bool, verbose bool) error {
362+
return app.executeRunE(runContext{path: path, strict: true, envName: envName, jsonOutput: jsonOutput, strictEnv: strictEnv, verbose: verbose})
304363
}
305364

306365
// printExpectationResult prints expectation results to stderr

cli/cmd/yapi/utils.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"sort"
88
"strings"
9+
"time"
910

1011
"yapi.run/cli/internal/config"
1112
"yapi.run/cli/internal/tui"
@@ -157,3 +158,116 @@ func isTerminal(f *os.File) bool {
157158
}
158159
return (stat.Mode() & os.ModeCharDevice) != 0
159160
}
161+
162+
// maxOutputSize is the threshold above which output is auto-saved to a file instead of printed.
163+
// 1MB is a reasonable limit - terminals struggle with larger outputs.
164+
const maxOutputSize = 1024 * 1024
165+
166+
// OutputResult holds the result of rendering output.
167+
type OutputResult struct {
168+
Printed bool // Output was printed to terminal
169+
SavedPath string // If not empty, output was saved to this file
170+
SaveErr error // If not nil, save failed with this error
171+
}
172+
173+
// renderOutput prints body to stdout, or saves to a file if too large.
174+
// configPath is used to generate a meaningful filename for auto-saved files.
175+
// When output is saved to file, no message is printed - caller handles that via OutputSavedError.
176+
func renderOutput(body string, configPath string) OutputResult {
177+
if len(body) <= maxOutputSize {
178+
fmt.Println(strings.TrimRight(body, "\n\r"))
179+
return OutputResult{Printed: true}
180+
}
181+
182+
// Output too large - save to file
183+
outputPath := generateOutputPath(configPath)
184+
if err := os.WriteFile(outputPath, []byte(body), 0600); err != nil {
185+
fmt.Fprintf(os.Stderr, "failed to save large output: %v\n", err)
186+
return OutputResult{SaveErr: err}
187+
}
188+
189+
return OutputResult{SavedPath: outputPath}
190+
}
191+
192+
// Logger provides leveled logging for verbose output.
193+
type Logger struct {
194+
verbose bool
195+
}
196+
197+
// NewLogger creates a logger. If verbose is false, Debug calls are no-ops.
198+
func NewLogger(verbose bool) *Logger {
199+
return &Logger{verbose: verbose}
200+
}
201+
202+
// Verbose prints a message only if verbose mode is enabled.
203+
func (l *Logger) Verbose(format string, args ...any) {
204+
if !l.verbose {
205+
return
206+
}
207+
fmt.Fprintf(os.Stderr, "[VERBOSE] "+format+"\n", args...)
208+
}
209+
210+
// Section prints a section header only if verbose mode is enabled.
211+
func (l *Logger) Section(name string) {
212+
if !l.verbose {
213+
return
214+
}
215+
fmt.Fprintf(os.Stderr, "\n=== %s ===\n", name)
216+
}
217+
218+
// Request logs request details in verbose mode.
219+
func (l *Logger) Request(method, url string, headers map[string]string, body string) {
220+
if !l.verbose {
221+
return
222+
}
223+
l.Section("REQUEST")
224+
fmt.Fprintf(os.Stderr, "%s %s\n", method, url)
225+
226+
if len(headers) > 0 {
227+
fmt.Fprintln(os.Stderr, "\nHeaders:")
228+
for k, v := range headers {
229+
fmt.Fprintf(os.Stderr, " %s: %s\n", k, v)
230+
}
231+
}
232+
233+
if body != "" {
234+
fmt.Fprintln(os.Stderr, "\nBody:")
235+
if len(body) > 1000 {
236+
fmt.Fprintf(os.Stderr, " %s... (%d bytes total)\n", body[:1000], len(body))
237+
} else {
238+
fmt.Fprintf(os.Stderr, " %s\n", body)
239+
}
240+
}
241+
fmt.Fprintln(os.Stderr, "")
242+
}
243+
244+
// Response logs response details in verbose mode.
245+
func (l *Logger) Response(statusCode int, headers map[string]string, duration time.Duration, bodySize int) {
246+
if !l.verbose {
247+
return
248+
}
249+
l.Section("RESPONSE")
250+
fmt.Fprintf(os.Stderr, "Status: %d\n", statusCode)
251+
fmt.Fprintf(os.Stderr, "Time: %s\n", duration)
252+
fmt.Fprintf(os.Stderr, "Size: %s\n", formatBytes(bodySize))
253+
254+
if len(headers) > 0 {
255+
fmt.Fprintln(os.Stderr, "\nHeaders:")
256+
for k, v := range headers {
257+
fmt.Fprintf(os.Stderr, " %s: %s\n", k, v)
258+
}
259+
}
260+
fmt.Fprintln(os.Stderr, "")
261+
}
262+
263+
// generateOutputPath creates a filename like: config-name-output-20060102-150405.json
264+
func generateOutputPath(configPath string) string {
265+
base := filepath.Base(configPath)
266+
name := strings.TrimSuffix(base, ".yapi.yml")
267+
if name == base {
268+
name = strings.TrimSuffix(base, filepath.Ext(base))
269+
}
270+
271+
timestamp := time.Now().Format("20060102-150405")
272+
return fmt.Sprintf("%s-output-%s.json", name, timestamp)
273+
}

cli/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/graphql-go/graphql v0.8.1
1515
github.com/itchyny/gojq v0.12.17
1616
github.com/jhump/protoreflect v1.17.0
17+
github.com/joho/godotenv v1.5.1
1718
github.com/mattn/go-isatty v0.0.20
1819
github.com/sahilm/fuzzy v0.1.1
1920
github.com/spf13/cobra v1.10.1
@@ -54,7 +55,6 @@ require (
5455
github.com/inconshreveable/mousetrap v1.1.0 // indirect
5556
github.com/itchyny/timefmt-go v0.1.6 // indirect
5657
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
57-
github.com/joho/godotenv v1.5.1 // indirect
5858
github.com/kevinburke/ssh_config v1.2.0 // indirect
5959
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
6060
github.com/mattn/go-localereader v0.0.1 // indirect

cli/internal/cli/commands/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ var cmdManifest = []CommandSpec{
7474
{Name: "env", Shorthand: "e", Type: "string", Default: "", Usage: "Target environment from yapi.config.yml"},
7575
{Name: "json", Type: "bool", Default: false, Usage: "Output result as JSON with full metadata"},
7676
{Name: "strict-env", Type: "bool", Default: false, Usage: "Strict env mode: error on missing env files, no OS env fallback"},
77+
{Name: "verbose", Shorthand: "v", Type: "bool", Default: false, Usage: "Show verbose output (request details, timing, headers)"},
7778
},
7879
},
7980
{

cli/internal/runner/runner.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type Result struct {
2929
BodyChars int
3030
BodyBytes int
3131
Headers map[string]string // Response headers
32+
OutputFile string // Path where output was saved (if output_file was specified)
3233
}
3334

3435
// Options for execution
@@ -81,6 +82,7 @@ func Run(ctx context.Context, exec executor.TransportFunc, req *domain.Request,
8182
}
8283

8384
// Write to output file if specified
85+
var savedOutputFile string
8486
if outputFile, ok := req.Metadata["output_file"]; ok && outputFile != "" {
8587
// Resolve relative paths against the config file directory
8688
if !filepath.IsAbs(outputFile) && opts.ConfigFilePath != "" {
@@ -95,6 +97,7 @@ func Run(ctx context.Context, exec executor.TransportFunc, req *domain.Request,
9597
if err := os.WriteFile(outputFile, []byte(body), 0600); err != nil {
9698
return nil, fmt.Errorf("failed to write output file '%s': %w", outputFile, err)
9799
}
100+
savedOutputFile = outputFile
98101
}
99102

100103
bodyLines := strings.Count(body, "\n") + 1
@@ -112,6 +115,7 @@ func Run(ctx context.Context, exec executor.TransportFunc, req *domain.Request,
112115
BodyChars: bodyChars,
113116
BodyBytes: bodyBytesLen,
114117
Headers: resp.Headers,
118+
OutputFile: savedOutputFile,
115119
}, nil
116120
}
117121

0 commit comments

Comments
 (0)