From 007d835821d5fc3fe3d3eb9bf07755dc6e47f28b Mon Sep 17 00:00:00 2001 From: jojo Date: Wed, 27 Sep 2023 10:59:22 -0300 Subject: [PATCH] :sparkles: feat: upload & parse file --- api/api.go | 48 ++++ api/transactionhandler.go | 92 +++++++- api/transactionhandler_test.go | 114 ++++++++++ docs/api.md | 17 +- docs/api.yml | 17 +- .../000001_add_transactions_table.up.sql | 3 +- postgres/storage.go | 12 +- postgres/storage_test.go | 211 +++++++++--------- transaction.go | 7 +- transaction_test.go | 4 +- 10 files changed, 388 insertions(+), 137 deletions(-) create mode 100644 api/transactionhandler_test.go diff --git a/api/api.go b/api/api.go index 7cbfab4..844b2d0 100644 --- a/api/api.go +++ b/api/api.go @@ -4,9 +4,14 @@ package api import ( "encoding/json" "fmt" + "io" "net/http" + "regexp" + "strings" "log/slog" + + "github.com/perebaj/contractus" ) // Error represents an error returned by the API. @@ -36,3 +41,46 @@ func send(w http.ResponseWriter, statusCode int, body interface{}) { slog.Error("Unable to encode body as JSON", "error", err) } } + +func parseFile(r *http.Request) (content string, err error) { + err = r.ParseMultipartForm(32 << 20) // 32MB + if err != nil { + return "", fmt.Errorf("failed to parse multipart form: %v", err) + } + + file, _, err := r.FormFile("file") + if err != nil { + return "", fmt.Errorf("failed to parse file: %v", err) + } + + defer func() { + _ = file.Close() + }() + + contentByte, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("failed to read file: %v", err) + } + return string(contentByte), nil +} + +func convert(content string) (t []contractus.Transaction, err error) { + content = strings.Replace(content, "\t", "", -1) + var re = regexp.MustCompile(`(?m).*$\n`) + for _, match := range re.FindAllString(content, -1) { + rawTransaction := Transaction{ + Type: match[0:1], + Date: match[1:26], + ProductDescription: match[26:56], + ProductPriceCents: match[56:66], + SellerName: match[66:], + } + transac, err := rawTransaction.Convert() + if err != nil { + return nil, fmt.Errorf("failed to convert transaction: %v", err) + } + t = append(t, *transac) + + } + return t, nil +} diff --git a/api/transactionhandler.go b/api/transactionhandler.go index bb62801..d0311b9 100644 --- a/api/transactionhandler.go +++ b/api/transactionhandler.go @@ -1,12 +1,21 @@ package api import ( + "context" + "fmt" "net/http" + "strconv" + "strings" + "time" + + "log/slog" "github.com/go-chi/chi/v5" + "github.com/perebaj/contractus" ) type transactionStorage interface { + SaveTransaction(ctx context.Context, t []contractus.Transaction) error } type transactionHandler struct { @@ -20,8 +29,10 @@ func RegisterHandler(r chi.Router, storage transactionStorage) { } const balanceProducer = "/balance/producer" - r.Method(http.MethodGet, balanceProducer, http.HandlerFunc(h.balance)) + + const upload = "/upload" + r.Method(http.MethodPost, upload, http.HandlerFunc(h.upload)) } func (s transactionHandler) balance(w http.ResponseWriter, _ *http.Request) { @@ -31,3 +42,82 @@ func (s transactionHandler) balance(w http.ResponseWriter, _ *http.Request) { send(w, http.StatusOK, t) } + +func (s transactionHandler) upload(w http.ResponseWriter, r *http.Request) { + content, err := parseFile(r) + if err != nil { + slog.Error("Failed to parse file", "error", err) + sendErr(w, http.StatusBadRequest, err) + return + } + + transactions, err := convert(content) + if err != nil { + slog.Error("Failed to convert transactions", "error", err, "content", content) + sendErr(w, http.StatusBadRequest, err) + return + } + err = s.storage.SaveTransaction(r.Context(), transactions) + if err != nil { + slog.Error("Failed to save transactions", "error", err, "transactions", transactions) + sendErr(w, http.StatusInternalServerError, err) + return + } + + send(w, http.StatusOK, nil) +} + +// Transaction represents the raw transaction from the file. +type Transaction struct { + Type string `json:"type"` + Date string `json:"date"` + ProductDescription string `json:"product_description"` + ProductPriceCents string `json:"product_price_cents"` + SellerName string `json:"seller_name"` +} + +// Convert transform the raw transaction to the business Transaction structure. +// TODO(JOJO) Join errors in one, and return all the errors. +func (t *Transaction) Convert() (*contractus.Transaction, error) { + typeInt, err := strconv.Atoi(t.Type) + if err != nil { + return nil, fmt.Errorf("failed to convert type: %v", err) + } + productPriceCentsInt, err := strconv.Atoi(t.ProductPriceCents) + if err != nil { + return nil, fmt.Errorf("failed to convert product price cents: %v", err) + } + // To play around timezone offset the format should be set up as follows: + // https://pkg.go.dev/time#pkg-constants + dateTime, err := time.Parse("2006-01-02T15:04:05-07:00", t.Date) + if err != nil { + return nil, fmt.Errorf("failed to convert date: %v", err) + } + + prodDesc := strings.TrimSpace(t.ProductDescription) + + sellerName := strings.Replace(t.SellerName, "\n", "", -1) + + transac := &contractus.Transaction{ + Type: typeInt, + Date: dateTime.UTC(), + ProductDescription: prodDesc, + ProductPriceCents: int64(productPriceCentsInt), + SellerName: sellerName, + } + + sellerType, err := transac.ConvertSellerType() + if err != nil { + return nil, fmt.Errorf("failed to convert seller type: %v", err) + } + + transacAction, err := transac.ConvertType() + if err != nil { + return nil, fmt.Errorf("failed to convert type: %v", err) + } + + transac.SellerType = sellerType + transac.Action = transacAction + + return transac, nil +} diff --git a/api/transactionhandler_test.go b/api/transactionhandler_test.go new file mode 100644 index 0000000..169cf11 --- /dev/null +++ b/api/transactionhandler_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/perebaj/contractus" +) + +type mockTransactionStorage struct{} + +func (m *mockTransactionStorage) SaveTransaction(_ context.Context, _ []contractus.Transaction) error { + return nil +} + +func TestTransactionHandlerUpload(t *testing.T) { + var b bytes.Buffer + w := multipart.NewWriter(&b) + fw, err := w.CreateFormFile("file", "test.txt") + if err != nil { + t.Fatal(err) + } + + fileContent := `12022-01-15T19:20:30-03:00CURSO DE BEM-ESTAR 0000012750JOSE CARLOS + 12021-12-03T11:46:02-03:00DOMINANDO INVESTIMENTOS 0000050000MARIA CANDIDA` + _, err = io.WriteString(fw, fileContent) + if err != nil { + t.Fatal(err) + } + err = w.Close() + if err != nil { + t.Fatal(err) + } + m := &mockTransactionStorage{} + r := chi.NewRouter() + RegisterHandler(r, m) + + req := httptest.NewRequest(http.MethodPost, "/upload", &b) + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp := httptest.NewRecorder() + + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status code %d, got %d", http.StatusOK, resp.Code) + } +} + +func TestConvert(t *testing.T) { + content := `12022-01-15T19:20:30-03:00CURSO DE BEM-ESTAR 0000012750JOSE CARLOS + 12021-12-03T11:46:02-03:00DOMINANDO INVESTIMENTOS 0000050000MARIA CANDIDA + ` + transac, err := convert(content) + if err != nil { + t.Fatal(err) + } + + want := []contractus.Transaction{ + { + Type: 1, + Date: time.Date(2022, 01, 15, 22, 20, 30, 0, time.UTC), + ProductDescription: "CURSO DE BEM-ESTAR", + ProductPriceCents: 12750, + SellerName: "JOSE CARLOS", + SellerType: "producer", + Action: "venda produtor", + }, + { + Type: 1, + Date: time.Date(2021, 12, 03, 14, 46, 02, 0, time.UTC), + ProductDescription: "DOMINANDO INVESTIMENTOS", + ProductPriceCents: 50000, + SellerName: "MARIA CANDIDA", + SellerType: "producer", + Action: "venda produtor", + }, + } + + if len(transac) == len(want) { + assert(t, transac[0].Type, want[0].Type) + assert(t, transac[0].Date.Format(time.RFC3339), want[0].Date.Format(time.RFC3339)) + assert(t, transac[0].ProductDescription, want[0].ProductDescription) + assert(t, transac[0].ProductPriceCents, want[0].ProductPriceCents) + assert(t, transac[0].SellerName, want[0].SellerName) + assert(t, transac[0].SellerType, want[0].SellerType) + assert(t, transac[0].Action, want[0].Action) + + assert(t, transac[1].Type, want[1].Type) + assert(t, transac[1].Date.Format(time.RFC3339), want[1].Date.Format(time.RFC3339)) + assert(t, transac[1].ProductDescription, want[1].ProductDescription) + assert(t, transac[1].ProductPriceCents, want[1].ProductPriceCents) + assert(t, transac[1].SellerName, want[1].SellerName) + assert(t, transac[1].SellerType, want[1].SellerType) + assert(t, transac[1].Action, want[1].Action) + } else { + t.Fatalf("expected %d transactions, got %d", len(want), len(transac)) + } +} + +func assert(t *testing.T, got, want interface{}) { + t.Helper() + + if got != want { + t.Fatalf("got %v want %v", got, want) + } +} diff --git a/docs/api.md b/docs/api.md index e6a3d5e..b2d2a43 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,17 +4,14 @@ Basic usage examples of the contractus API # Endpoints -## `/jojo` - -Request: - -``` -curl http://localhost:8080/jojo -``` +## `/upload` -Response: +Request: ``` -Jojo is aweasome! + curl -i -X 'POST' \ + 'http://localhost:8080/upload' \ + -H 'accept: application/json' \ + -H 'Content-Type: multipart/form-data' \ + -F 'file=@sales.txt;type=text/plain' ``` - diff --git a/docs/api.yml b/docs/api.yml index 1a0c3bd..d97d11e 100644 --- a/docs/api.yml +++ b/docs/api.yml @@ -33,11 +33,6 @@ paths: responses: '200': description: OK - content: - application/json: - schema: - type: string - default: 'File uploaded!' /transactions: get: summary: Return all transactions @@ -55,9 +50,9 @@ paths: type: array items: $ref: '#/components/schemas/Transaction' - count: + total: type: integer - description: Total number of transactions + description: Number of transactions /balance/affiliate: get: summary: Return balance for an affiliate @@ -147,3 +142,11 @@ components: enum: - affiliate - producer + action: + type: string + description: Transaction action + enum: + - venda produtor + - venda afiliado + - comissão afiliado + - comissão produtor diff --git a/postgres/migrations/000001_add_transactions_table.up.sql b/postgres/migrations/000001_add_transactions_table.up.sql index 9a91fe5..1e7fdff 100644 --- a/postgres/migrations/000001_add_transactions_table.up.sql +++ b/postgres/migrations/000001_add_transactions_table.up.sql @@ -4,6 +4,7 @@ CREATE TABLE transactions ( product_description TEXT NOT NULL, product_price_cents INT NOT NULL, seller_name TEXT NOT NULL, - seller_type TEXT NOT NULL + seller_type TEXT NOT NULL, + action TEXT NOT NULL ); diff --git a/postgres/storage.go b/postgres/storage.go index cb9e037..f9e7426 100644 --- a/postgres/storage.go +++ b/postgres/storage.go @@ -20,11 +20,11 @@ func NewStorage(db *sqlx.DB) *Storage { } // SaveTransaction is responsible for saving a transaction into the database. -func (s Storage) SaveTransaction(ctx context.Context, t *contractus.Transaction) error { - _, err := s.db.ExecContext(ctx, ` - INSERT INTO transactions (type, date, product_description, product_price_cents, seller_name, seller_type) - VALUES ($1, $2, $3, $4, $5, $6) - `, t.Type, t.Date, t.ProductDescription, t.ProductPriceCents, t.SellerName, t.SellerType) +func (s Storage) SaveTransaction(ctx context.Context, t []contractus.Transaction) error { + _, err := s.db.NamedExecContext(ctx, ` + INSERT INTO transactions (type, date, product_description, product_price_cents, seller_name, seller_type, action) + VALUES (:type, :date, :product_description, :product_price_cents, :seller_name, :seller_type, :action) + `, t) return err } @@ -35,7 +35,7 @@ func (s Storage) Transactions(ctx context.Context) (contractus.TransactionRespon var transactions []contractus.Transaction err := s.db.SelectContext(ctx, &transactions, ` - SELECT type, date, product_description, product_price_cents, seller_name, seller_type + SELECT type, date, product_description, product_price_cents, seller_name, seller_type, action FROM transactions `) if err != nil { diff --git a/postgres/storage_test.go b/postgres/storage_test.go index 7041e0b..6c3c346 100644 --- a/postgres/storage_test.go +++ b/postgres/storage_test.go @@ -78,16 +78,19 @@ func TestStorageSaveTransaction(t *testing.T) { db := OpenDB(t) storage := postgres.NewStorage(db) ctx := context.Background() - want := contractus.Transaction{ - Type: 1, - Date: time.Now().UTC(), - ProductDescription: "Product description", - ProductPriceCents: 1000, - SellerName: "John Doe", - SellerType: "producer", + want := []contractus.Transaction{ + { + Type: 1, + Date: time.Now().UTC(), + ProductDescription: "Product description", + ProductPriceCents: 1000, + SellerName: "John Doe", + SellerType: "producer", + Action: "venda produtor", + }, } - err := storage.SaveTransaction(ctx, &want) + err := storage.SaveTransaction(ctx, want) if err != nil { t.Fatalf("error saving transaction: %v", err) } @@ -98,46 +101,45 @@ func TestStorageSaveTransaction(t *testing.T) { t.Fatalf("error getting transaction: %v", err) } - assert(t, got.Type, want.Type) - assert(t, got.Date.Format(time.RFC3339), want.Date.Format(time.RFC3339)) - assert(t, got.ProductDescription, want.ProductDescription) - assert(t, got.ProductPriceCents, want.ProductPriceCents) - assert(t, got.SellerName, want.SellerName) - assert(t, got.SellerType, want.SellerType) + assert(t, got.Type, want[0].Type) + assert(t, got.Date.Format(time.RFC3339), want[0].Date.Format(time.RFC3339)) + assert(t, got.ProductDescription, want[0].ProductDescription) + assert(t, got.ProductPriceCents, want[0].ProductPriceCents) + assert(t, got.SellerName, want[0].SellerName) + assert(t, got.SellerType, want[0].SellerType) } func TestStorageTransactions(t *testing.T) { db := OpenDB(t) storage := postgres.NewStorage(db) ctx := context.Background() - want := contractus.Transaction{ - Type: 1, - Date: time.Now().UTC(), - ProductDescription: "Product description", - ProductPriceCents: 1000, - SellerName: "John Doe", - SellerType: "producer", - } - want2 := contractus.Transaction{ - Type: 2, - Date: time.Now().UTC(), - ProductDescription: "Product description 2", - ProductPriceCents: 2000, - SellerName: "John Doe 2", - SellerType: "affiliate", + want := []contractus.Transaction{ + { + Type: 1, + Date: time.Now().UTC(), + ProductDescription: "Product description", + ProductPriceCents: 1000, + SellerName: "John Doe", + SellerType: "producer", + Action: "venda produtor", + }, + { + Type: 2, + Date: time.Now().UTC(), + ProductDescription: "Product description 2", + ProductPriceCents: 2000, + SellerName: "John Doe 2", + SellerType: "affiliate", + Action: "venda afiliado", + }, } - err := storage.SaveTransaction(ctx, &want) + err := storage.SaveTransaction(ctx, want) if err != nil { t.Fatalf("error saving transaction 1: %v", err) } - err = storage.SaveTransaction(ctx, &want2) - if err != nil { - t.Fatalf("error saving transaction 2: %v", err) - } - got, err := storage.Transactions(ctx) if err != nil { t.Fatalf("error getting transactions: %v", err) @@ -148,19 +150,21 @@ func TestStorageTransactions(t *testing.T) { // the order based on the insertion order. // Reference: https://dba.stackexchange.com/questions/95822/does-postgres-preserve-insertion-order-of-records if got.Total == 2 { - assert(t, got.Transactions[0].Type, want.Type) - assert(t, got.Transactions[0].Date.Format(time.RFC3339), want.Date.Format(time.RFC3339)) - assert(t, got.Transactions[0].ProductDescription, want.ProductDescription) - assert(t, got.Transactions[0].ProductPriceCents, want.ProductPriceCents) - assert(t, got.Transactions[0].SellerName, want.SellerName) - assert(t, got.Transactions[0].SellerType, want.SellerType) - - assert(t, got.Transactions[1].Type, want2.Type) - assert(t, got.Transactions[1].Date.Format(time.RFC3339), want2.Date.Format(time.RFC3339)) - assert(t, got.Transactions[1].ProductDescription, want2.ProductDescription) - assert(t, got.Transactions[1].ProductPriceCents, want2.ProductPriceCents) - assert(t, got.Transactions[1].SellerName, want2.SellerName) - assert(t, got.Transactions[1].SellerType, want2.SellerType) + assert(t, got.Transactions[0].Type, want[0].Type) + assert(t, got.Transactions[0].Date.Format(time.RFC3339), want[0].Date.Format(time.RFC3339)) + assert(t, got.Transactions[0].ProductDescription, want[0].ProductDescription) + assert(t, got.Transactions[0].ProductPriceCents, want[0].ProductPriceCents) + assert(t, got.Transactions[0].SellerName, want[0].SellerName) + assert(t, got.Transactions[0].SellerType, want[0].SellerType) + assert(t, got.Transactions[0].Action, want[0].Action) + + assert(t, got.Transactions[1].Type, want[1].Type) + assert(t, got.Transactions[1].Date.Format(time.RFC3339), want[1].Date.Format(time.RFC3339)) + assert(t, got.Transactions[1].ProductDescription, want[1].ProductDescription) + assert(t, got.Transactions[1].ProductPriceCents, want[1].ProductPriceCents) + assert(t, got.Transactions[1].SellerName, want[1].SellerName) + assert(t, got.Transactions[1].SellerType, want[1].SellerType) + assert(t, got.Transactions[1].Action, want[1].Action) } else { t.Fatal("error getting transactions: expected 2 transactions") } @@ -171,46 +175,39 @@ func TestStorageBalance(t *testing.T) { storage := postgres.NewStorage(db) ctx := context.Background() - transac1 := contractus.Transaction{ - Type: 1, - Date: time.Now().UTC(), - ProductDescription: "Product description", - ProductPriceCents: 12750, - SellerName: "JOSE CARLOS", - SellerType: "producer", - } - - transac2 := contractus.Transaction{ - Type: 3, - Date: time.Now().UTC(), - ProductDescription: "Product description 2", - ProductPriceCents: 4500, - SellerName: "JOSE CARLOS", - SellerType: "producer", + transactions1 := []contractus.Transaction{ + { + Type: 1, + Date: time.Now().UTC(), + ProductDescription: "Product description", + ProductPriceCents: 12750, + SellerName: "JOSE CARLOS", + SellerType: "producer", + Action: "venda produtor", + }, + { + Type: 3, + Date: time.Now().UTC(), + ProductDescription: "Product description 2", + ProductPriceCents: 4500, + SellerName: "JOSE CARLOS", + SellerType: "producer", + Action: "comissao paga", + }, + { + Type: 1, + Date: time.Now().UTC(), + ProductDescription: "Product description 3", + ProductPriceCents: 12750, + SellerName: "JOSE CARLOS", + SellerType: "producer", + Action: "venda produtor", + }, } - transac3 := contractus.Transaction{ - Type: 1, - Date: time.Now().UTC(), - ProductDescription: "Product description 3", - ProductPriceCents: 12750, - SellerName: "JOSE CARLOS", - SellerType: "producer", - } - - err := storage.SaveTransaction(ctx, &transac1) + err := storage.SaveTransaction(ctx, transactions1) if err != nil { - t.Fatalf("error saving transaction 1: %v", err) - } - - err = storage.SaveTransaction(ctx, &transac2) - if err != nil { - t.Fatalf("error saving transaction 2: %v", err) - } - - err = storage.SaveTransaction(ctx, &transac3) - if err != nil { - t.Fatalf("error saving transaction 3: %v", err) + t.Fatalf("error saving transactions 1: %v", err) } got, err := storage.Balance(ctx, "producer", "JOSE CARLOS") @@ -221,32 +218,30 @@ func TestStorageBalance(t *testing.T) { assert(t, got.Balance, int64(21000)) assert(t, got.SellerName, "JOSE CARLOS") - transac1 = contractus.Transaction{ - Type: 2, - Date: time.Now().UTC(), - ProductDescription: "Product description", - ProductPriceCents: 155000, - SellerName: "CARLOS BATISTA", - SellerType: "affiliate", - } - - transac2 = contractus.Transaction{ - Type: 4, - Date: time.Now().UTC(), - ProductDescription: "Product description 2", - ProductPriceCents: 50000, - SellerName: "CARLOS BATISTA", - SellerType: "affiliate", - } - - err = storage.SaveTransaction(ctx, &transac1) - if err != nil { - t.Fatalf("error saving transaction 1: %v", err) + transactions2 := []contractus.Transaction{ + { + Type: 2, + Date: time.Now().UTC(), + ProductDescription: "Product description", + ProductPriceCents: 155000, + SellerName: "CARLOS BATISTA", + SellerType: "affiliate", + Action: "venda afiliado", + }, + { + Type: 4, + Date: time.Now().UTC(), + ProductDescription: "Product description 2", + ProductPriceCents: 50000, + SellerName: "CARLOS BATISTA", + SellerType: "affiliate", + Action: "comissao recebida", + }, } - err = storage.SaveTransaction(ctx, &transac2) + err = storage.SaveTransaction(ctx, transactions2) if err != nil { - t.Fatalf("error saving transaction 2: %v", err) + t.Fatalf("error saving transactions 2: %v", err) } got, err = storage.Balance(ctx, "affiliate", "CARLOS BATISTA") diff --git a/transaction.go b/transaction.go index 8e1e8d5..c720cbc 100644 --- a/transaction.go +++ b/transaction.go @@ -26,9 +26,11 @@ type Transaction struct { ProductPriceCents int64 `json:"product_price_cents" db:"product_price_cents"` SellerName string `json:"seller_name" db:"seller_name"` SellerType string `json:"seller_type" db:"seller_type"` + Action string `json:"action" db:"action"` } -func (t Transaction) typ() (string, error) { +// ConvertType convert the transaction type from his code to the string representation. +func (t *Transaction) ConvertType() (string, error) { switch t.Type { case 1: return "venda produtor", nil @@ -43,7 +45,8 @@ func (t Transaction) typ() (string, error) { } } -func (t Transaction) sellerType() (string, error) { +// ConvertSellerType convert the seller type from his code to the string representation. +func (t *Transaction) ConvertSellerType() (string, error) { switch t.Type { case 1, 3: return "producer", nil diff --git a/transaction_test.go b/transaction_test.go index 48d7b63..b6e400a 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -49,7 +49,7 @@ func TestTransactionTyp(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.transaction.typ() + got, err := tt.transaction.ConvertType() if (err != nil) != tt.wantErr { t.Errorf("typ() error = %v, wantErr %v", err, tt.wantErr) } @@ -107,7 +107,7 @@ func TestTransactionSellerType(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.transaction.sellerType() + got, err := tt.transaction.ConvertSellerType() if (err != nil) != tt.wantErr { t.Errorf("sellerType() error = %v, wantErr %v", err, tt.wantErr) }