initial webapp
This commit is contained in:
1 parent
6c989c338c
commit
d32f2b3a66
18 files changed
+1145
-16
No files matched your search
@@ -0,0 +1,167 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user