Skip to content

Commit 4344a6f

Browse files
authored
Merge pull request #1 from etilite/dev
feat: add initial version of builder
2 parents 8ef0c6f + 2c473dc commit 4344a6f

File tree

22 files changed

+726
-1
lines changed

22 files changed

+726
-1
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
## dev
2+
/.idea/

Makefile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.PHONY: dev-up dev-down run test test-race clean
2+
3+
dev-up:
4+
docker-compose --file ./build/docker-compose.yml up -d --build --remove-orphans
5+
6+
dev-down:
7+
docker-compose --file ./build/docker-compose.yml down --rmi all -v
8+
9+
run:
10+
CGO_ENABLED=0 go build -ldflags='-w -s' -o app ./main.go && HTTP_ADDR=:8080 ./app
11+
12+
test:
13+
go test -v -shuffle=on -count=2 -short -cover ./...
14+
15+
test-race:
16+
go test -race ./...
17+
18+
clean:
19+
rm app

README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,41 @@
1-
# Init
1+
# Xlsx builder
2+
## Usage
3+
`make dev-up`
4+
5+
`POST http://localhost:8080/invoice/`
6+
7+
```JSON
8+
{
9+
"id": "1234",
10+
"date": "2023-07-29",
11+
"amount": "10",
12+
"client": {
13+
"fullName": "John Smith",
14+
"accountId": "US0001",
15+
"email": "[email protected]"
16+
},
17+
"header": [
18+
"date",
19+
"id",
20+
"price"
21+
],
22+
"data": [
23+
[
24+
"01.01.2023",
25+
"1",
26+
"10"
27+
],
28+
[
29+
"02.01.2023",
30+
"2",
31+
"20"
32+
],
33+
[
34+
"03.01.2023",
35+
"3",
36+
"33"
37+
]
38+
]
39+
}
40+
```
41+
service responses with `.xlsx` file

build/Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
ARG GO_VERSION=1.20.2
2+
ARG DISTROLESS_IMAGE=gcr.io/distroless/static:nonroot
3+
############################
4+
# STEP 1 build executable binary
5+
############################
6+
FROM golang:${GO_VERSION} as builder
7+
8+
# Ensure ca-certficates are up to date
9+
RUN update-ca-certificates
10+
11+
WORKDIR /app/
12+
13+
# use modules
14+
COPY go.mod go.sum ./
15+
16+
ENV GO111MODULE=on
17+
RUN go mod download && go mod verify
18+
19+
COPY . .
20+
21+
# Build the static binary
22+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
23+
-ldflags='-w -s -extldflags "-static"' -a \
24+
-o app .
25+
26+
############################
27+
# STEP 2 build a small image
28+
############################
29+
FROM ${DISTROLESS_IMAGE}
30+
31+
COPY --from=builder --chown=nonroot:nonroot /app/app /app
32+
33+
ENTRYPOINT ["/app"]

build/docker-compose.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
version: '3.8'
2+
3+
services:
4+
xlsx-builder:
5+
build:
6+
context: ./../
7+
dockerfile: ./build/Dockerfile
8+
container_name: xlsx-builder
9+
ports:
10+
- "8080:8080"
11+
environment:
12+
- HTTP_ADDR=:8080

builder/builder.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package builder
2+
3+
import (
4+
"bytes"
5+
"github.com/xuri/excelize/v2"
6+
"log"
7+
)
8+
9+
type Builder struct{}
10+
11+
func NewBuilder() *Builder {
12+
return &Builder{}
13+
}
14+
15+
func (b *Builder) Build(rows [][]string) (*bytes.Buffer, error) {
16+
f := excelize.NewFile()
17+
defer func() {
18+
if err := f.Close(); err != nil {
19+
log.Print(err)
20+
}
21+
}()
22+
err := fillRows(rows, f)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
buf, err := f.WriteToBuffer()
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
return buf, nil
33+
}
34+
35+
func fillRows(rows [][]string, f *excelize.File) error {
36+
for i, r := range rows {
37+
cell, err := excelize.CoordinatesToCellName(1, i+1)
38+
if err != nil {
39+
return err
40+
}
41+
err = f.SetSheetRow("Sheet1", cell, &r)
42+
if err != nil {
43+
return err
44+
}
45+
}
46+
return nil
47+
}

builder/builder_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package builder
2+
3+
import (
4+
"github.com/xuri/excelize/v2"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestBuild(t *testing.T) {
11+
tests := map[string]struct {
12+
rows [][]string
13+
}{
14+
"empty table": {rows: [][]string{}},
15+
"2x2 table": {rows: [][]string{{"a", "b"}, {"c", "d"}}},
16+
}
17+
for name, tc := range tests {
18+
t.Run(name, func(t *testing.T) {
19+
tc := tc
20+
t.Parallel()
21+
22+
b := NewBuilder()
23+
24+
buf, err := b.Build(tc.rows)
25+
if err != nil {
26+
t.Error(err)
27+
}
28+
29+
f, err := excelize.OpenReader(strings.NewReader(buf.String()))
30+
if err != nil {
31+
t.Error(err)
32+
}
33+
34+
rows, err := f.GetRows("Sheet1")
35+
if err != nil {
36+
t.Error("got error getting rows")
37+
}
38+
39+
if !reflect.DeepEqual(rows, tc.rows) {
40+
t.Errorf("result mismatch in test %s: want %s, got %s", name, tc.rows, rows)
41+
}
42+
})
43+
}
44+
}

delivery/http/builder.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package http
2+
3+
import (
4+
"bytes"
5+
)
6+
7+
type Builder interface {
8+
Build(rows [][]string) (*bytes.Buffer, error)
9+
}

delivery/http/handler.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package http
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
"net/http"
8+
"strconv"
9+
)
10+
11+
type XlsxHandler struct {
12+
builder Builder
13+
}
14+
15+
func NewXlsxHandler(b Builder) *XlsxHandler {
16+
return &XlsxHandler{builder: b}
17+
}
18+
19+
func (h *XlsxHandler) handleSheet(newSheet func() Sheet) http.HandlerFunc {
20+
return func(w http.ResponseWriter, r *http.Request) {
21+
sheet := newSheet()
22+
//fmt.Printf("sheet: %T, &sheet: %T\n", sheet, &sheet)
23+
err := json.NewDecoder(r.Body).Decode(&sheet)
24+
//fmt.Printf("sheet: %v, &sheet: %v\n", sheet, &sheet)
25+
if err != nil {
26+
err = fmt.Errorf("failed to decode JSON: %w", err)
27+
log.Print(err)
28+
http.Error(w, err.Error(), http.StatusBadRequest)
29+
return
30+
}
31+
32+
buf, err := h.builder.Build(sheet.Rows())
33+
if err != nil {
34+
log.Print(err)
35+
http.Error(w, err.Error(), http.StatusInternalServerError)
36+
return
37+
}
38+
39+
h.setHeaders(w, int64(buf.Len()))
40+
_, err = buf.WriteTo(w)
41+
if err != nil {
42+
log.Print(err)
43+
http.Error(w, err.Error(), http.StatusInternalServerError)
44+
return
45+
}
46+
}
47+
}
48+
49+
func (h *XlsxHandler) setHeaders(w http.ResponseWriter, length int64) {
50+
w.Header().Set("Content-Type", "application/octet-stream")
51+
//w.Header().Set("Data-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
52+
w.Header().Set("Content-Disposition", "attachment; filename=sheet.xlsx")
53+
w.Header().Set("Content-Transfer-Encoding", "binary")
54+
w.Header().Set("Expires", "0")
55+
w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
56+
}

delivery/http/handler_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package http
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
. "xlsx-builder/internal/testing"
11+
)
12+
13+
type mockBuilder struct {
14+
buf *bytes.Buffer
15+
err error
16+
}
17+
18+
func (b *mockBuilder) Build([][]string) (*bytes.Buffer, error) {
19+
return b.buf, b.err
20+
}
21+
22+
type mockSheet struct {
23+
rows [][]string
24+
}
25+
26+
func (s mockSheet) Rows() [][]string {
27+
return s.rows
28+
}
29+
30+
func TestCreateInvoiceXlsx(t *testing.T) {
31+
mockFactory := func() Sheet {
32+
return &mockSheet{}
33+
}
34+
t.Run("success", func(t *testing.T) {
35+
t.Parallel()
36+
37+
buf := &bytes.Buffer{}
38+
buf.Write([]byte("body-file-contents"))
39+
builder := &mockBuilder{buf: buf}
40+
server := NewXlsxHandler(builder)
41+
handleFunc := server.handleSheet(mockFactory)
42+
43+
requestBody := strings.NewReader("{}")
44+
request, _ := http.NewRequest(http.MethodPost, "/invoice/", requestBody)
45+
response := httptest.NewRecorder()
46+
47+
handleFunc(response, request)
48+
49+
AssertStatusCode(t, response, http.StatusOK)
50+
AssertBody(t, response, "body-file-contents")
51+
assertHeaders(t, response)
52+
})
53+
54+
t.Run("bad request", func(t *testing.T) {
55+
t.Parallel()
56+
57+
builder := &mockBuilder{}
58+
server := NewXlsxHandler(builder)
59+
handleFunc := server.handleSheet(mockFactory)
60+
61+
requestBody := strings.NewReader("")
62+
request, _ := http.NewRequest(http.MethodPost, "/invoice/", requestBody)
63+
response := httptest.NewRecorder()
64+
65+
handleFunc(response, request)
66+
67+
AssertStatusCode(t, response, http.StatusBadRequest)
68+
AssertBody(t, response, "failed to decode JSON: EOF\n")
69+
})
70+
71+
t.Run("internal server error", func(t *testing.T) {
72+
t.Parallel()
73+
74+
builder := &mockBuilder{err: errors.New("internal server error")}
75+
server := NewXlsxHandler(builder)
76+
handleFunc := server.handleSheet(mockFactory)
77+
78+
requestBody := strings.NewReader("{}")
79+
request, _ := http.NewRequest(http.MethodPost, "/invoice/", requestBody)
80+
response := httptest.NewRecorder()
81+
82+
handleFunc(response, request)
83+
84+
AssertStatusCode(t, response, http.StatusInternalServerError)
85+
AssertBody(t, response, "internal server error\n")
86+
})
87+
}
88+
89+
func assertHeaders(t *testing.T, response *httptest.ResponseRecorder) {
90+
AssertHeader(t, response, "Content-Disposition", "attachment; filename=sheet.xlsx")
91+
AssertHeader(t, response, "Content-Type", "application/octet-stream")
92+
//AssertHeader(t, response, "Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
93+
AssertHeader(t, response, "Content-Transfer-Encoding", "binary")
94+
AssertHeader(t, response, "Expires", "0")
95+
AssertHeader(t, response, "Content-Length", "18")
96+
}

0 commit comments

Comments
 (0)