package web import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "github.com/nmoniz/any2anexoj/internal" "github.com/nmoniz/any2anexoj/internal/prettyprinter" "github.com/nmoniz/any2anexoj/internal/trading212" ) // MaxUploadSize caps the size of the multipart upload. Trading212 CSV exports // for an active account tend to be a few MB; 32 MiB is generous while still // preventing abuse. const MaxUploadSize = 32 << 20 // tableResponse is the JSON shape returned by both the upload and table // endpoints. type tableResponse struct { Table string `json:"table"` Rows int `json:"rows"` } // errorResponse is the JSON shape returned for all 4xx/5xx responses. type errorResponse struct { Error string `json:"error"` } // writeJSON marshals v as JSON and writes it with the given status code. Any // encoding error is returned so the caller can decide how to surface it. func writeJSON(w http.ResponseWriter, status int, v any) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) return json.NewEncoder(w).Encode(v) } // writeError writes a JSON {"error": "..."} response. func writeError(w http.ResponseWriter, status int, msg string) error { return writeJSON(w, status, errorResponse{Error: msg}) } // ClientError tags an error that originates from the uploaded CSV (bad // formatting, missing fields, malformed values, etc.). The upload handler // uses it to map these to HTTP 400 instead of HTTP 500: a malformed CSV is a // client mistake, not a server problem. type ClientError struct { Err error } func (e *ClientError) Error() string { return e.Err.Error() } func (e *ClientError) Unwrap() error { return e.Err } // csvRecordReader wraps a trading212 RecordReader so every error from // ReadRecord is tagged as a *ClientError. This lets the handler distinguish // "the CSV is bad" from "the server is broken" via errors.As. The Unwrap // method on *ClientError preserves errors.Is(err, io.EOF) for BuildReport's // end-of-stream detection. type csvRecordReader struct { inner internal.RecordReader } func (w *csvRecordReader) ReadRecord(ctx context.Context) (internal.Record, error) { rec, err := w.inner.ReadRecord(ctx) if err != nil { return nil, &ClientError{Err: err} } return rec, nil } // handleUpload parses a multipart form upload, runs the report pipeline, and // stores the rendered table for retrieval via GET /api/table. func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(MaxUploadSize); err != nil { // Malformed multipart bodies (wrong Content-Type, truncated body, // missing boundary, etc.) all land here. if writeErr := writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to parse multipart form: %v", err)); writeErr != nil { http.Error(w, writeErr.Error(), http.StatusInternalServerError) } return } file, _, err := r.FormFile("csv") if err != nil { if errors.Is(err, http.ErrMissingFile) { if writeErr := writeError(w, http.StatusBadRequest, "missing csv file"); writeErr != nil { http.Error(w, writeErr.Error(), http.StatusInternalServerError) } return } if writeErr := writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to read csv file: %v", err)); writeErr != nil { http.Error(w, writeErr.Error(), http.StatusInternalServerError) } return } defer file.Close() table, rows, buildErr := s.buildRenderedTable(r.Context(), file) if buildErr != nil { // CSV parse failures (tagged as *ClientError by csvRecordReader) are // user mistakes and should surface as HTTP 400. Anything else - // internal.BuildReport failures, localizer creation failures, etc. - // is a server problem and gets HTTP 500. var clientErr *ClientError if errors.As(buildErr, &clientErr) { if writeErr := writeError(w, http.StatusBadRequest, clientErr.Error()); writeErr != nil { http.Error(w, writeErr.Error(), http.StatusInternalServerError) } return } if writeErr := writeError(w, http.StatusInternalServerError, buildErr.Error()); writeErr != nil { http.Error(w, writeErr.Error(), http.StatusInternalServerError) } return } s.state.Set(table, rows) if err := writeJSON(w, http.StatusOK, tableResponse{Table: table, Rows: rows}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } // handleGetTable returns the most recently rendered table. When no upload has // happened yet the response contains an empty table string and zero rows. func (s *Server) handleGetTable(w http.ResponseWriter, _ *http.Request) { table, rows := s.state.Get() if err := writeJSON(w, http.StatusOK, tableResponse{Table: table, Rows: rows}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } // buildRenderedTable wires the CSV reader, the aggregator, BuildReport, and the // pretty-printer into a single pipeline. It returns the rendered table string // and the row count. func (s *Server) buildRenderedTable(ctx context.Context, r io.Reader) (string, int, error) { innerReader := trading212.NewRecordReader(r, s.figiClient) reader := &csvRecordReader{inner: innerReader} writer := internal.NewAggregatorWriter() if err := internal.BuildReport(ctx, reader, writer, internal.WithSelector(internal.Any())); err != nil { return "", 0, fmt.Errorf("failed to build report: %w", err) } loc, err := prettyprinter.NewLocalizer("pt") if err != nil { return "", 0, fmt.Errorf("failed to create localizer: %w", err) } var buf bytes.Buffer prettyprinter.NewPrettyPrinter(&buf, loc).Render(writer) // Row count is the number of report items the aggregator ended up with, // since each item becomes a row in the rendered table. rows := 0 for range writer.Iter() { rows++ } return buf.String(), rows, nil }