Skip to content

Commit

Permalink
✨ feat: upload & parse file
Browse files Browse the repository at this point in the history
  • Loading branch information
perebaj committed Sep 27, 2023
1 parent a50eafd commit 007d835
Show file tree
Hide file tree
Showing 10 changed files with 388 additions and 137 deletions.
48 changes: 48 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
92 changes: 91 additions & 1 deletion api/transactionhandler.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) {
Expand All @@ -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
}
114 changes: 114 additions & 0 deletions api/transactionhandler_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 7 additions & 10 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '[email protected];type=text/plain'
```

17 changes: 10 additions & 7 deletions docs/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@ paths:
responses:
'200':
description: OK
content:
application/json:
schema:
type: string
default: 'File uploaded!'
/transactions:
get:
summary: Return all transactions
Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion postgres/migrations/000001_add_transactions_table.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

12 changes: 6 additions & 6 deletions postgres/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
Loading

0 comments on commit 007d835

Please sign in to comment.