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 @@ + + + + + + + + + + + + + simple-blog + + + +
+ + + diff --git a/simple-blog/webapp/public/logo192.png b/simple-blog/webapp/public/logo192.png new file mode 100644 index 0000000..3e8150d Binary files /dev/null and b/simple-blog/webapp/public/logo192.png differ diff --git a/simple-blog/webapp/public/logo512.png b/simple-blog/webapp/public/logo512.png new file mode 100644 index 0000000..4d16459 Binary files /dev/null and b/simple-blog/webapp/public/logo512.png differ diff --git a/simple-blog/webapp/public/manifest.json b/simple-blog/webapp/public/manifest.json new file mode 100644 index 0000000..fccb935 --- /dev/null +++ b/simple-blog/webapp/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "simple-blog", + "name": "simple-blog", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/simple-blog/webapp/public/robots.txt b/simple-blog/webapp/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/simple-blog/webapp/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/simple-blog/webapp/src/App.css b/simple-blog/webapp/src/App.css new file mode 100644 index 0000000..5d47141 --- /dev/null +++ b/simple-blog/webapp/src/App.css @@ -0,0 +1,34 @@ +body { + margin-top: 5%; + padding-right: 5%; + padding-left: 5%; + font-size: larger; +} + +@media screen and (min-width: 800px) { + body { + padding-right: 15%; + padding-left: 15%; + } +} + +@media screen and (min-width: 1600px) { + body { + padding-right: 30%; + padding-left: 30%; + } +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; + background-color: #b3e6ff; +} + +.title { + text-align: center; +} + +.logo { + text-align: center; +} diff --git a/simple-blog/webapp/src/App.js b/simple-blog/webapp/src/App.js new file mode 100644 index 0000000..798cdb3 --- /dev/null +++ b/simple-blog/webapp/src/App.js @@ -0,0 +1,23 @@ +import React from 'react'; +import './App.css'; +import Logo from './Logo'; +import {Tech} from "./tech/Tech"; + +export function App() { + return ( +
+

simple-blog

+
+
+ This project is generated with goxygen. +

The following list of technologies comes from + a REST API call to the Go-based back end. Find + and change the corresponding code + in webapp/src/tech/Tech.js + and server/web/app.go. + +

+
+ ); +} diff --git a/simple-blog/webapp/src/App.test.js b/simple-blog/webapp/src/App.test.js new file mode 100644 index 0000000..34dba21 --- /dev/null +++ b/simple-blog/webapp/src/App.test.js @@ -0,0 +1,9 @@ +import React from 'react'; +import {render} from '@testing-library/react'; +import {App} from './App'; + +test('renders learn react link', () => { + const {getByText} = render(); + const linkElement = getByText(/goxygen/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/simple-blog/webapp/src/Logo.js b/simple-blog/webapp/src/Logo.js new file mode 100644 index 0000000..5bf36ed --- /dev/null +++ b/simple-blog/webapp/src/Logo.js @@ -0,0 +1,322 @@ +import React from "react"; + +const Logo = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Logo; \ No newline at end of file diff --git a/simple-blog/webapp/src/index.css b/simple-blog/webapp/src/index.css new file mode 100644 index 0000000..cd1a456 --- /dev/null +++ b/simple-blog/webapp/src/index.css @@ -0,0 +1,8 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/simple-blog/webapp/src/index.js b/simple-blog/webapp/src/index.js new file mode 100644 index 0000000..1ed441f --- /dev/null +++ b/simple-blog/webapp/src/index.js @@ -0,0 +1,6 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import './index.css'; +import {App} from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/simple-blog/webapp/src/setupTests.js b/simple-blog/webapp/src/setupTests.js new file mode 100644 index 0000000..8194b6f --- /dev/null +++ b/simple-blog/webapp/src/setupTests.js @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom/extend-expect'; \ No newline at end of file diff --git a/simple-blog/webapp/src/tech/Tech.css b/simple-blog/webapp/src/tech/Tech.css new file mode 100644 index 0000000..055349a --- /dev/null +++ b/simple-blog/webapp/src/tech/Tech.css @@ -0,0 +1,3 @@ +.technologies { + margin-top: 5px; +} diff --git a/simple-blog/webapp/src/tech/Tech.js b/simple-blog/webapp/src/tech/Tech.js new file mode 100644 index 0000000..feb0bcc --- /dev/null +++ b/simple-blog/webapp/src/tech/Tech.js @@ -0,0 +1,29 @@ +import React, {Component} from "react"; +import axios from "axios"; +import "./Tech.css" + +export class Tech extends Component { + state = { + technologies: [] + }; + + componentDidMount() { + axios.get(`${process.env.REACT_APP_API_URL}/api/technologies`) + .then(resp => this.setState({ + technologies: resp.data + })); + } + + render() { + const technologies = this.state.technologies.map((technology, i) => +
  • + {technology.name}: {technology.details} +
  • + ); + return ( + + ); + } +}