diff --git a/simple-blog/.dockerignore b/simple-blog/.dockerignore new file mode 100644 index 0000000..aa07dbf --- /dev/null +++ b/simple-blog/.dockerignore @@ -0,0 +1,2 @@ +webapp/node_modules/ +webapp/build/ diff --git a/simple-blog/Dockerfile b/simple-blog/Dockerfile new file mode 100644 index 0000000..eb62d39 --- /dev/null +++ b/simple-blog/Dockerfile @@ -0,0 +1,14 @@ +FROM node:16.20-alpine3.18 AS JS_BUILD +COPY webapp /webapp +WORKDIR /webapp +RUN npm install && npm run build + +FROM golang:1.22.1-alpine3.18 AS GO_BUILD +COPY server /server +WORKDIR /server +RUN go build -o /go/bin/server + +FROM alpine:3.18.6 +COPY --from=JS_BUILD /webapp/build* ./webapp/ +COPY --from=GO_BUILD /go/bin/server ./ +CMD ./server diff --git a/simple-blog/README.md b/simple-blog/README.md new file mode 100644 index 0000000..f8ecf99 --- /dev/null +++ b/simple-blog/README.md @@ -0,0 +1,58 @@ +# Simple-blog + +## Environment setup + +You need to have [Go](https://golang.org/), +[Node.js](https://nodejs.org/), +[Docker](https://www.docker.com/), and +[Docker Compose](https://docs.docker.com/compose/) +(comes pre-installed with Docker on Mac and Windows) +installed on your computer. + +Verify the tools by running the following commands: + +```sh +go version +npm --version +docker --version +docker-compose --version +``` + +## Start in development mode + +In the project directory run the command (you might +need to prepend it with `sudo` depending on your setup): +```sh +docker-compose -f docker-compose-dev.yml up +``` + +This starts a local PostgreSQL database on `localhost:5432`. +The database will be populated with test records from the +[init-db.sql](init-db.sql) file. + +Navigate to the `server` folder and start the back end: + +```sh +cd server +go run server.go +``` +The back end will serve on http://localhost:8080. + +Navigate to the `webapp` folder, install dependencies, +and start the front end development server by running: + +```sh +cd webapp +npm install +npm start +``` +The application will be available on http://localhost:3000. + +## Start in production mode + +Perform: +```sh +docker-compose up +``` +This will build the application and start it together with +its database. Access the application on http://localhost:8080. diff --git a/simple-blog/docker-compose-dev.yml b/simple-blog/docker-compose-dev.yml new file mode 100644 index 0000000..f8a4484 --- /dev/null +++ b/simple-blog/docker-compose-dev.yml @@ -0,0 +1,12 @@ +version: "3.8" +services: + dev_db: + image: postgres:15.4-alpine3.18 + environment: + POSTGRES_PASSWORD: pass + POSTGRES_USER: goxygen + POSTGRES_DB: goxygen + ports: + - 5432:5432 + volumes: + - ./init-db.sql:/docker-entrypoint-initdb.d/init.sql diff --git a/simple-blog/docker-compose.yml b/simple-blog/docker-compose.yml new file mode 100644 index 0000000..47a85d4 --- /dev/null +++ b/simple-blog/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.8" +services: + app: + build: . + container_name: app + ports: + - 8080:8080 + depends_on: + - db + environment: + profile: prod + db_pass: pass + db: + image: postgres:15.4-alpine3.18 + environment: + POSTGRES_PASSWORD: pass + POSTGRES_USER: goxygen + POSTGRES_DB: goxygen + volumes: + - ./init-db.sql:/docker-entrypoint-initdb.d/init.sql + - ./pgdata:/var/lib/postgresql/data diff --git a/simple-blog/init-db.sql b/simple-blog/init-db.sql new file mode 100644 index 0000000..9337724 --- /dev/null +++ b/simple-blog/init-db.sql @@ -0,0 +1,13 @@ +CREATE TABLE technologies ( + name VARCHAR(255), + details VARCHAR(255) +); +insert into technologies values ( + 'Go', 'An open source programming language that makes it easy to build simple and efficient software.' +); +insert into technologies values ( + 'JavaScript', 'A lightweight, interpreted, or just-in-time compiled programming language with first-class functions.' +); +insert into technologies values ( + 'PostgreSQL', 'A powerful, open source object-relational database system' +); diff --git a/simple-blog/server/db/db.go b/simple-blog/server/db/db.go new file mode 100644 index 0000000..0bc88d6 --- /dev/null +++ b/simple-blog/server/db/db.go @@ -0,0 +1,36 @@ +package db + +import ( + "database/sql" + "simple-blog/model" +) + +type DB interface { + GetTechnologies() ([]*model.Technology, error) +} + +type PostgresDB struct { + db *sql.DB +} + +func NewDB(db *sql.DB) DB { + return PostgresDB{db: db} +} + +func (d PostgresDB) GetTechnologies() ([]*model.Technology, error) { + rows, err := d.db.Query("select name, details from technologies") + if err != nil { + return nil, err + } + defer rows.Close() + var tech []*model.Technology + for rows.Next() { + t := new(model.Technology) + err = rows.Scan(&t.Name, &t.Details) + if err != nil { + return nil, err + } + tech = append(tech, t) + } + return tech, nil +} diff --git a/simple-blog/server/go.mod b/simple-blog/server/go.mod new file mode 100644 index 0000000..ad3758b --- /dev/null +++ b/simple-blog/server/go.mod @@ -0,0 +1,5 @@ +module simple-blog + +go 1.22 + +require github.com/lib/pq v1.10.9 diff --git a/simple-blog/server/go.sum b/simple-blog/server/go.sum new file mode 100644 index 0000000..d043746 --- /dev/null +++ b/simple-blog/server/go.sum @@ -0,0 +1,2 @@ +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= \ No newline at end of file diff --git a/simple-blog/server/model/technology.go b/simple-blog/server/model/technology.go new file mode 100644 index 0000000..a3d9210 --- /dev/null +++ b/simple-blog/server/model/technology.go @@ -0,0 +1,6 @@ +package model + +type Technology struct { + Name string `json:"name"` + Details string `json:"details"` +} diff --git a/simple-blog/server/server.go b/simple-blog/server/server.go new file mode 100644 index 0000000..bdb0d5a --- /dev/null +++ b/simple-blog/server/server.go @@ -0,0 +1,35 @@ +package main + +import ( + "database/sql" + "log" + "os" + "simple-blog/db" + "simple-blog/web" + + _ "github.com/lib/pq" +) + +func main() { + d, err := sql.Open("postgres", dataSource()) + if err != nil { + log.Fatal(err) + } + defer d.Close() + // CORS is enabled only in prod profile + cors := os.Getenv("profile") == "prod" + app := web.NewApp(db.NewDB(d), cors) + err = app.Serve() + log.Println("Error", err) +} + +func dataSource() string { + host := "localhost" + pass := "pass" + if os.Getenv("profile") == "prod" { + host = "db" + pass = os.Getenv("db_pass") + } + return "postgresql://" + host + ":5432/goxygen" + + "?user=goxygen&sslmode=disable&password=" + pass +} diff --git a/simple-blog/server/web/app.go b/simple-blog/server/web/app.go new file mode 100644 index 0000000..2b3b774 --- /dev/null +++ b/simple-blog/server/web/app.go @@ -0,0 +1,63 @@ +package web + +import ( + "encoding/json" + "log" + "net/http" + "simple-blog/db" +) + +type App struct { + d db.DB + handlers map[string]http.HandlerFunc +} + +func NewApp(d db.DB, cors bool) App { + app := App{ + d: d, + handlers: make(map[string]http.HandlerFunc), + } + techHandler := app.GetTechnologies + if !cors { + techHandler = disableCors(techHandler) + } + app.handlers["/api/technologies"] = techHandler + app.handlers["/"] = http.FileServer(http.Dir("/webapp")).ServeHTTP + return app +} + +func (a *App) Serve() error { + for path, handler := range a.handlers { + http.Handle(path, handler) + } + log.Println("Web server is available on port 8080") + return http.ListenAndServe(":8080", nil) +} + +func (a *App) GetTechnologies(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + technologies, err := a.d.GetTechnologies() + if err != nil { + sendErr(w, http.StatusInternalServerError, err.Error()) + return + } + err = json.NewEncoder(w).Encode(technologies) + if err != nil { + sendErr(w, http.StatusInternalServerError, err.Error()) + } +} + +func sendErr(w http.ResponseWriter, code int, message string) { + resp, _ := json.Marshal(map[string]string{"error": message}) + http.Error(w, string(resp), code) +} + +// Needed in order to disable CORS for local development +func disableCors(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "*") + w.Header().Set("Access-Control-Allow-Headers", "*") + h(w, r) + } +} diff --git a/simple-blog/server/web/app_test.go b/simple-blog/server/web/app_test.go new file mode 100644 index 0000000..27b09ef --- /dev/null +++ b/simple-blog/server/web/app_test.go @@ -0,0 +1,57 @@ +package web + +import ( + "errors" + "net/http" + "net/http/httptest" + "simple-blog/model" + "testing" +) + +type MockDb struct { + tech []*model.Technology + err error +} + +func (m *MockDb) GetTechnologies() ([]*model.Technology, error) { + return m.tech, m.err +} + +func TestApp_GetTechnologies(t *testing.T) { + app := App{d: &MockDb{ + tech: []*model.Technology{ + {"Tech1", "Details1"}, + {"Tech2", "Details2"}, + }, + }} + + r, _ := http.NewRequest("GET", "/api/technologies", nil) + w := httptest.NewRecorder() + + app.GetTechnologies(w, r) + + if w.Code != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", w.Code, http.StatusOK) + } + + want := `[{"name":"Tech1","details":"Details1"},{"name":"Tech2","details":"Details2"}]` + "\n" + if got := w.Body.String(); got != want { + t.Errorf("handler returned unexpected body: got %v want %v", got, want) + } +} + +func TestApp_GetTechnologies_WithDBError(t *testing.T) { + app := App{d: &MockDb{ + tech: nil, + err: errors.New("unknown error"), + }} + + r, _ := http.NewRequest("GET", "/api/technologies", nil) + w := httptest.NewRecorder() + + app.GetTechnologies(w, r) + + if w.Code != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", w.Code, http.StatusOK) + } +} diff --git a/simple-blog/webapp/.env.development b/simple-blog/webapp/.env.development new file mode 100644 index 0000000..deb105c --- /dev/null +++ b/simple-blog/webapp/.env.development @@ -0,0 +1 @@ +REACT_APP_API_URL=http://localhost:8080 \ No newline at end of file diff --git a/simple-blog/webapp/.env.production b/simple-blog/webapp/.env.production new file mode 100644 index 0000000..4452883 --- /dev/null +++ b/simple-blog/webapp/.env.production @@ -0,0 +1 @@ +REACT_APP_API_URL= \ No newline at end of file diff --git a/simple-blog/webapp/package.json b/simple-blog/webapp/package.json new file mode 100644 index 0000000..7a56d54 --- /dev/null +++ b/simple-blog/webapp/package.json @@ -0,0 +1,37 @@ +{ + "name": "simple-blog", + "version": "0.1.0", + "private": true, + "dependencies": { + "axios": "~1.2.1", + "react": "~18.2.0", + "react-dom": "~18.2.0", + "react-scripts": "~5.0.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "~5.16.5", + "@testing-library/react": "~13.4.0", + "@testing-library/user-event": "~14.4.3" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/simple-blog/webapp/public/favicon.ico b/simple-blog/webapp/public/favicon.ico new file mode 100644 index 0000000..674af8f Binary files /dev/null and b/simple-blog/webapp/public/favicon.ico differ diff --git a/simple-blog/webapp/public/index.html b/simple-blog/webapp/public/index.html new file mode 100644 index 0000000..317322f --- /dev/null +++ b/simple-blog/webapp/public/index.html @@ -0,0 +1,43 @@ + + +
+ + + + + + + + + +webapp/src/tech/Tech.js
+
and server/web/app.go
.
+