├── LICENSE ├── streamlit_app ├── src │ ├── __init__.py │ ├── models.py │ ├── formatting.py │ ├── services.py │ └── views.py ├── requirements.txt ├── entrypoint.sh ├── .dockerignore ├── Dockerfile ├── app.py └── .streamlit │ └── config.example.toml ├── .gitignore ├── nginx ├── index.html ├── Dockerfile └── conf │ └── project.conf ├── postgres ├── Dockerfile ├── scripts │ ├── apply_notify.sql │ ├── apply_update_timestamp.sql │ ├── trigger_update_timestamp.sql │ ├── trigger_notify.sql │ ├── init_tables.sql │ └── seed_row.sql └── init-user-db.sh ├── backend ├── go.mod ├── Dockerfile ├── go.sum ├── models │ └── note.go ├── handler │ ├── handler.go │ ├── errors.go │ └── notes.go ├── db │ ├── db.go │ └── note.go └── main.go ├── example.env ├── docker-compose.yml └── README.md /LICENSE: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /streamlit_app/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | venv 3 | config.toml 4 | *.db -------------------------------------------------------------------------------- /streamlit_app/requirements.txt: -------------------------------------------------------------------------------- 1 | httpx==0.22.0 2 | pydantic==1.9.0 3 | streamlit==1.6.0 4 | -------------------------------------------------------------------------------- /streamlit_app/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Running Streamlit app" 4 | streamlit run app.py 5 | -------------------------------------------------------------------------------- /streamlit_app/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest_cache 3 | .cache 4 | venv 5 | env 6 | coverage 7 | *_logs -------------------------------------------------------------------------------- /nginx/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |Nothing to see here.
7 | 8 | 9 | -------------------------------------------------------------------------------- /postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.2 2 | 3 | COPY init-user-db.sh /docker-entrypoint-initdb.d/init-user-db.sh 4 | COPY ./scripts /home/postgres 5 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.21 2 | 3 | WORKDIR /usr/src/nginx 4 | COPY . /usr/src/nginx 5 | 6 | COPY /conf/project.conf /etc/nginx/conf.d/ 7 | RUN rm /etc/nginx/conf.d/default.conf -------------------------------------------------------------------------------- /postgres/scripts/apply_notify.sql: -------------------------------------------------------------------------------- 1 | CREATE TRIGGER notify_on_message_insert 2 | AFTER INSERT ON note 3 | FOR EACH ROW 4 | EXECUTE PROCEDURE notify_on_insert (); 5 | 6 | -------------------------------------------------------------------------------- /postgres/scripts/apply_update_timestamp.sql: -------------------------------------------------------------------------------- 1 | CREATE TRIGGER set_timestamp 2 | BEFORE UPDATE ON note 3 | FOR EACH ROW 4 | EXECUTE PROCEDURE trigger_set_timestamp (); 5 | 6 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module fullstack/backend 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/go-chi/chi v1.5.4 7 | github.com/go-chi/render v1.0.1 8 | github.com/lib/pq v1.10.4 9 | ) 10 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST=db 2 | POSTGRES_PORT=5432 3 | POSTGRES_DB=dev_db 4 | POSTGRES_USER=dev_user 5 | POSTGRES_PASSWORD=dev_pass 6 | 7 | HTTPX_LOG_LEVEL=debug 8 | 9 | BACKEND_HOST=backend 10 | BACKEND_PORT=3000 -------------------------------------------------------------------------------- /postgres/scripts/trigger_update_timestamp.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION trigger_set_timestamp () 2 | RETURNS TRIGGER 3 | AS $$ 4 | BEGIN 5 | NEW.updated_timestamp = NOW(); 6 | RETURN NEW; 7 | END; 8 | $$ 9 | LANGUAGE plpgsql; 10 | 11 | -------------------------------------------------------------------------------- /postgres/scripts/trigger_notify.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION notify_on_insert () 2 | RETURNS TRIGGER 3 | AS $$ 4 | BEGIN 5 | PERFORM 6 | pg_notify('channel_note', CAST(row_to_json(NEW) AS text)); 7 | RETURN NULL; 8 | END; 9 | $$ 10 | LANGUAGE plpgsql; 11 | 12 | -------------------------------------------------------------------------------- /postgres/scripts/init_tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS note ( 2 | rowid serial NOT NULL PRIMARY KEY, 3 | created_timestamp timestamptz NOT NULL DEFAULT NOW(), 4 | updated_timestamp timestamptz NOT NULL DEFAULT NOW(), 5 | username varchar(140) NOT NULL, 6 | body varchar(140) NOT NULL 7 | ); 8 | 9 | -------------------------------------------------------------------------------- /postgres/scripts/seed_row.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO note (rowid, username, body) 2 | VALUES (1, 'SYSTEM', ':beers: Auto Generated Note!!! :tada:') 3 | ON CONFLICT 4 | DO NOTHING; 5 | 6 | SELECT 7 | setval('note_rowid_seq', ( 8 | SELECT 9 | MAX(rowid) 10 | FROM note)); 11 | 12 | -------------------------------------------------------------------------------- /streamlit_app/src/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | @dataclass 3 | class BaseNote: 4 | """Note Entity for Creation / Handling without database ID""" 5 | 6 | created_timestamp: int 7 | updated_timestamp: int 8 | username: str 9 | body: str 10 | 11 | 12 | @dataclass 13 | class Note(BaseNote): 14 | """Note Entity to model database entry""" 15 | 16 | rowid: int 17 | -------------------------------------------------------------------------------- /streamlit_app/src/formatting.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | def display_timestamp(timestamp: int) -> datetime: 4 | """Return python datetime from utc timestamp""" 5 | return datetime.fromtimestamp(timestamp, timezone.utc) 6 | 7 | 8 | def utc_timestamp() -> int: 9 | """Return current utc timestamp rounded to nearest int""" 10 | return int(datetime.utcnow().timestamp()) 11 | 12 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17-alpine3.15 as builder 2 | COPY go.mod go.sum /go/src/fullstack/backend/ 3 | WORKDIR /go/src/fullstack/backend 4 | RUN go mod download 5 | COPY . /go/src/fullstack/backend 6 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o build/app fullstack/backend 7 | 8 | FROM alpine 9 | RUN apk add --no-cache ca-certificates && update-ca-certificates 10 | COPY --from=builder /go/src/fullstack/backend/build/app /usr/bin/app 11 | ENTRYPOINT ["/usr/bin/app"] -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= 2 | github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= 3 | github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8= 4 | github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= 5 | github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= 6 | github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 7 | -------------------------------------------------------------------------------- /postgres/init-user-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Running Database Initialization" 5 | 6 | psql -v ON_ERROR_STOP=1 --echo-all --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ 7 | -c "BEGIN TRANSACTION;" \ 8 | -f /home/postgres/init_tables.sql \ 9 | -f /home/postgres/trigger_notify.sql \ 10 | -f /home/postgres/trigger_update_timestamp.sql \ 11 | -f /home/postgres/apply_notify.sql \ 12 | -f /home/postgres/apply_update_timestamp.sql \ 13 | -f /home/postgres/seed_row.sql \ 14 | -c "COMMIT;" 15 | -------------------------------------------------------------------------------- /streamlit_app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-buster 2 | 3 | # Don't buffer logs or write pyc 4 | ENV PYTHONUNBUFFERED 1 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | 7 | # Set Virtual env as active python environment 8 | ENV VIRTUAL_ENV=/opt/venv 9 | RUN python3 -m venv $VIRTUAL_ENV 10 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 11 | 12 | # Install all requirements 13 | COPY requirements*.txt /tmp/ 14 | RUN pip install --upgrade pip && pip install --no-cache-dir -r /tmp/requirements.txt 15 | 16 | # Run as non-root user 17 | RUN useradd --create-home appuser 18 | WORKDIR /home/appuser 19 | USER appuser 20 | 21 | COPY . . 22 | 23 | ENTRYPOINT [ "/bin/bash" ] 24 | CMD [ "entrypoint.sh" ] 25 | -------------------------------------------------------------------------------- /backend/models/note.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type Note struct { 9 | Rowid int `json:"rowid"` 10 | Username string `json:"username"` 11 | Body string `json:"body"` 12 | CreatedTimestamp float32 `json:"created_timestamp"` 13 | UpdatedTimestamp float32 `json:"updated_timestamp"` 14 | } 15 | 16 | type NoteList struct { 17 | Notes []Note `json:"notes"` 18 | } 19 | 20 | func (note *Note) Bind(r *http.Request) error { 21 | if note.Username == "" { 22 | return fmt.Errorf("username must be non empty") 23 | } 24 | return nil 25 | } 26 | 27 | func (*NoteList) Render(w http.ResponseWriter, r *http.Request) error { 28 | return nil 29 | } 30 | 31 | func (*Note) Render(w http.ResponseWriter, r *http.Request) error { 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /streamlit_app/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG) 3 | 4 | import streamlit as st 5 | 6 | import src.views as views 7 | 8 | PAGES = { 9 | "Read Note Feed": views.render_read, # Read first for display default 10 | "Create a Note": views.render_create, 11 | "Update a Note": views.render_update, 12 | "Delete a Note": views.render_delete, 13 | "About": views.render_about, 14 | } 15 | 16 | def main() -> None: 17 | """Main Streamlit App Entry""" 18 | st.header(f"The Littlest Fullstack App + Postgres + Go!") 19 | render_sidebar() 20 | 21 | 22 | def render_sidebar() -> None: 23 | """Provides Selectbox Drop Down for which view to render""" 24 | choice = st.sidebar.radio("Go To Page:", PAGES.keys()) 25 | render_func = PAGES.get(choice) 26 | render_func() 27 | 28 | 29 | if __name__ == "__main__": 30 | main() 31 | -------------------------------------------------------------------------------- /backend/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fullstack/backend/db" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/go-chi/chi/middleware" 9 | "github.com/go-chi/render" 10 | ) 11 | 12 | var dbInstance db.Database 13 | 14 | func NewHandler(db db.Database) http.Handler { 15 | router := chi.NewRouter() 16 | dbInstance = db 17 | router.MethodNotAllowed(methodNotAllowedHandler) 18 | router.NotFound(notFoundHandler) 19 | router.Use(middleware.Logger) 20 | router.Route("/notes", notes) 21 | return router 22 | } 23 | 24 | func methodNotAllowedHandler(w http.ResponseWriter, r *http.Request) { 25 | w.Header().Set("Content-type", "application/json") 26 | w.WriteHeader(405) 27 | render.Render(w, r, ErrMethodNotAllowed) 28 | } 29 | 30 | func notFoundHandler(w http.ResponseWriter, r *http.Request) { 31 | w.Header().Set("Content-type", "application/json") 32 | w.WriteHeader(400) 33 | render.Render(w, r, ErrNotFound) 34 | } 35 | -------------------------------------------------------------------------------- /backend/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | 8 | _ "github.com/lib/pq" 9 | ) 10 | 11 | var ErrRowNotFound = fmt.Errorf("no matching record") 12 | 13 | type Database struct { 14 | Connection *sql.DB 15 | } 16 | 17 | type PGConnection struct { 18 | User string 19 | Password string 20 | DbName string 21 | Host string 22 | Port int 23 | } 24 | 25 | func Initialize(pg PGConnection) (Database, error) { 26 | db := Database{} 27 | connectionString := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", 28 | pg.Host, pg.Port, pg.User, pg.Password, pg.DbName) 29 | log.Println("Attempting to connect to postgres") 30 | connection, err := sql.Open("postgres", connectionString) 31 | 32 | if err != nil { 33 | return db, err 34 | } 35 | 36 | db.Connection = connection 37 | err = db.Connection.Ping() 38 | 39 | if err != nil { 40 | return db, err 41 | } 42 | 43 | log.Println("Database connection established") 44 | return db, nil 45 | 46 | } 47 | -------------------------------------------------------------------------------- /nginx/conf/project.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080; 3 | server_name nginx-proxy; 4 | 5 | location / { 6 | proxy_pass http://streamlit-app:8501/; 7 | } 8 | 9 | location ^~ /static { 10 | proxy_pass http://streamlit-app:8501/static/; 11 | } 12 | location ^~ /healthz { 13 | proxy_pass http://streamlit-app:8501/healthz; 14 | } 15 | location ^~ /vendor { 16 | proxy_pass http://streamlit-app:8501/vendor; 17 | } 18 | location /stream { 19 | proxy_pass http://streamlit-app:8501/stream; 20 | proxy_http_version 1.1; 21 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 22 | proxy_set_header Host $host; 23 | proxy_set_header Upgrade $http_upgrade; 24 | proxy_set_header Connection "upgrade"; 25 | proxy_set_header Sec-WebSocket-Extensions $http_sec_websocket_extensions; 26 | proxy_read_timeout 86400; 27 | proxy_set_header X-Real-IP $remote_addr; 28 | proxy_set_header X-Forwarded-Proto $scheme; 29 | } 30 | } -------------------------------------------------------------------------------- /backend/handler/errors.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/render" 7 | ) 8 | 9 | type ErrorResponse struct { 10 | Err error `json:"-"` 11 | StatusCode int `json:"-"` 12 | StatusText string `json:"status_text"` 13 | Message string `json:"message"` 14 | } 15 | 16 | var ( 17 | ErrMethodNotAllowed = &ErrorResponse{StatusCode: 405, Message: "Method not allowed"} 18 | ErrNotFound = &ErrorResponse{StatusCode: 404, Message: "Resource not found"} 19 | ErrBadRequest = &ErrorResponse{StatusCode: 400, Message: "Bad request"} 20 | ) 21 | 22 | func (e *ErrorResponse) Render(w http.ResponseWriter, r *http.Request) error { 23 | render.Status(r, e.StatusCode) 24 | return nil 25 | } 26 | 27 | func ErrorRenderer(err error) *ErrorResponse { 28 | return &ErrorResponse{ 29 | Err: err, 30 | StatusCode: 400, 31 | StatusText: "Bad request", 32 | Message: err.Error(), 33 | } 34 | } 35 | 36 | func ServerErrorRenderer(err error) *ErrorResponse { 37 | return &ErrorResponse{ 38 | Err: err, 39 | StatusCode: 500, 40 | StatusText: "Internal server error", 41 | Message: err.Error(), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx-proxy: 3 | restart: "${DOCKER_RESTART_POLICY:-unless-stopped}" 4 | build: ./nginx 5 | ports: 6 | - "8080:8080" 7 | depends_on: 8 | - streamlit-app 9 | - db 10 | - backend 11 | 12 | streamlit-app: 13 | ports: 14 | - "8501:8501" 15 | build: ./streamlit_app 16 | command: entrypoint.sh 17 | volumes: 18 | - ./streamlit_app:/home/appuser:z 19 | env_file: 20 | - ./.env.dev 21 | restart: "${DOCKER_RESTART_POLICY:-unless-stopped}" 22 | depends_on: 23 | - backend 24 | 25 | backend: 26 | ports: 27 | - "3000:3000" 28 | build: ./backend 29 | env_file: 30 | - ./.env.dev 31 | depends_on: 32 | - db 33 | restart: "${DOCKER_RESTART_POLICY:-unless-stopped}" 34 | 35 | db: 36 | image: postgres:14 37 | build: ./postgres 38 | command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"] 39 | volumes: 40 | - postgres_data:/var/lib/postgresql/data/pgdata 41 | env_file: 42 | - ./.env.dev 43 | environment: 44 | PGDATA: /var/lib/postgresql/data/pgdata/ 45 | 46 | volumes: 47 | postgres_data: -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "fullstack/backend/db" 7 | "fullstack/backend/handler" 8 | "log" 9 | "net" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | "strconv" 14 | "syscall" 15 | "time" 16 | ) 17 | 18 | func main() { 19 | addr := ":" + os.Getenv("BACKEND_PORT") 20 | listener, err := net.Listen("tcp", addr) 21 | if err != nil { 22 | log.Fatalf("Error occurred: %v", err) 23 | } 24 | port, err := strconv.Atoi(os.Getenv("POSTGRES_PORT")) 25 | if err != nil { 26 | log.Fatal("Port non-integer ", err) 27 | } 28 | pg := db.PGConnection{ 29 | User: os.Getenv("POSTGRES_USER"), 30 | Password: os.Getenv("POSTGRES_PASSWORD"), 31 | DbName: os.Getenv("POSTGRES_DB"), 32 | Port: port, 33 | Host: os.Getenv("POSTGRES_HOST"), 34 | } 35 | 36 | database, err := db.Initialize(pg) 37 | if err != nil { 38 | log.Fatalf("Could not set up database %v", err) 39 | } 40 | defer database.Connection.Close() 41 | 42 | httpHandler := handler.NewHandler(database) 43 | 44 | server := &http.Server{ 45 | Handler: httpHandler, 46 | } 47 | 48 | go func() { 49 | server.Serve(listener) 50 | }() 51 | 52 | defer Stop(server) 53 | log.Printf("Started server on %s", addr) 54 | ch := make(chan os.Signal, 1) 55 | signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) 56 | log.Println(fmt.Sprint(<-ch)) 57 | log.Println("Stopping API server.") 58 | } 59 | 60 | func Stop(server *http.Server) { 61 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 62 | defer cancel() 63 | if err := server.Shutdown(ctx); err != nil { 64 | log.Printf("Could not shut down server correctly: %v\n", err) 65 | os.Exit(1) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /streamlit_app/src/services.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict 2 | import os 3 | from typing import List 4 | import logging 5 | 6 | import httpx 7 | 8 | from src.models import Note 9 | from src.models import Note, BaseNote 10 | 11 | BACKEND_HOST = os.getenv("BACKEND_HOST", "backend") 12 | BACKEND_PORT = os.getenv("BACKEND_PORT", "3000") 13 | 14 | CHAR_LIMIT = 140 15 | 16 | class NoteService: 17 | """Namespace for Database Related Note Operations""" 18 | 19 | @staticmethod 20 | def list_all_notes() -> List[Note]: 21 | response = httpx.get(f"http://{BACKEND_HOST}:{BACKEND_PORT}/notes") 22 | logging.info(response) 23 | json_notes = response.json() 24 | notes = [Note(**note) for note in json_notes["notes"]] 25 | return notes 26 | 27 | @staticmethod 28 | def create_note(note: BaseNote) -> None: 29 | """Create a Note in the database""" 30 | response = httpx.post( 31 | f"http://{BACKEND_HOST}:{BACKEND_PORT}/notes", json=asdict(note) 32 | ) 33 | return response 34 | 35 | @staticmethod 36 | def update_note(note: Note) -> None: 37 | """Replace a Note in the database""" 38 | response = httpx.put( 39 | f"http://{BACKEND_HOST}:{BACKEND_PORT}/notes/{note.rowid}", 40 | json=asdict(note), 41 | ) 42 | return response 43 | 44 | @staticmethod 45 | def delete_note(note: Note) -> None: 46 | """Delete a Note in the database""" 47 | # DELETE spec doesn't include json body: https://github.com/encode/httpx/discussions/1587 48 | response = httpx.request( 49 | method="delete", 50 | url=f"http://{BACKEND_HOST}:{BACKEND_PORT}/notes/{note.rowid}", 51 | json=asdict(note), 52 | ) 53 | return response 54 | -------------------------------------------------------------------------------- /backend/db/note.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fullstack/backend/models" 6 | "log" 7 | ) 8 | 9 | func (db Database) GetAllNotes() (*models.NoteList, error) { 10 | notes := &models.NoteList{} 11 | query := `SELECT rowid, date_part('epoch', created_timestamp), date_part('epoch', updated_timestamp), 12 | username, body FROM note ORDER BY rowid DESC;` 13 | log.Println("Getting All Notes") 14 | rows, err := db.Connection.Query(query) 15 | 16 | if err != nil { 17 | return notes, err 18 | } 19 | 20 | for rows.Next() { 21 | var note models.Note 22 | err := rows.Scan(¬e.Rowid, ¬e.CreatedTimestamp, ¬e.UpdatedTimestamp, ¬e.Username, ¬e.Body) 23 | 24 | if err != nil { 25 | log.Println("Error Scanning Row", err) 26 | return notes, err 27 | } 28 | 29 | notes.Notes = append(notes.Notes, note) 30 | } 31 | log.Println("Returning ", len(notes.Notes), " Rows") 32 | return notes, nil 33 | } 34 | 35 | func (db Database) GetNoteById(noteId int) (models.Note, error) { 36 | note := models.Note{} 37 | query := `SELECT rowid, date_part('epoch', created_timestamp), date_part('epoch', updated_timestamp), 38 | username, body FROM note WHERE rowid = $1;` 39 | log.Println("Getting Note with ID: ", noteId) 40 | row := db.Connection.QueryRow(query, noteId) 41 | err := row.Scan(¬e.Rowid, ¬e.CreatedTimestamp, ¬e.UpdatedTimestamp, ¬e.Username, ¬e.Body) 42 | 43 | if err == sql.ErrNoRows { 44 | log.Println("No Rows Found ", err) 45 | return note, ErrRowNotFound 46 | } else { 47 | log.Println("Returning ", note) 48 | return note, err 49 | } 50 | } 51 | 52 | func (db Database) AddNote(note *models.Note) error { 53 | var id int 54 | query := `INSERT into note(username, body) 55 | VALUES($1, $2) RETURNING rowid;` 56 | log.Println("Adding New Note", note) 57 | rows := db.Connection.QueryRow(query, note.Username, note.Body) 58 | err := rows.Scan(&id) 59 | if err != nil { 60 | log.Println("Couldn't Add Note", err) 61 | return err 62 | } 63 | note.Rowid = id 64 | log.Printf("Returning ", note) 65 | return nil 66 | } 67 | 68 | func (db Database) UpdateNote(noteId int, noteData models.Note) (models.Note, error) { 69 | note := models.Note{} 70 | query := `UPDATE note SET username=$1, body=$2 WHERE rowid=$3 71 | RETURNING rowid, username, body, date_part('epoch', created_timestamp), date_part('epoch', updated_timestamp);` 72 | log.Println("Updating Note with ID ", noteId, ". Setting Fields ", noteData) 73 | row := db.Connection.QueryRow(query, noteData.Username, noteData.Body, noteId) 74 | err := row.Scan(¬e.Rowid, ¬e.Username, ¬e.Body, ¬e.CreatedTimestamp, ¬e.UpdatedTimestamp) 75 | if err != nil { 76 | if err == sql.ErrNoRows { 77 | log.Println("Note Not Updated ", err) 78 | return note, ErrRowNotFound 79 | } 80 | log.Println("Error Scanning Result ", err) 81 | return note, err 82 | } 83 | log.Println(note) 84 | return note, nil 85 | } 86 | 87 | func (db Database) DeleteNote(noteId int) error { 88 | query := `DELETE FROM note WHERE rowid = $1 RETURNING rowid;` 89 | log.Println("Deleting Note with ID ", noteId) 90 | _, err := db.Connection.Exec(query, noteId) 91 | if err == sql.ErrNoRows { 92 | log.Println("Note not Deleted ", err) 93 | return ErrRowNotFound 94 | } else { 95 | return err 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /backend/handler/notes.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "fullstack/backend/db" 7 | "fullstack/backend/models" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | 12 | "github.com/go-chi/chi" 13 | "github.com/go-chi/render" 14 | ) 15 | 16 | type NoteKey struct { 17 | key string 18 | } 19 | 20 | var noteIdKey = NoteKey{key: "note-key"} 21 | 22 | func notes(router chi.Router) { 23 | router.Get("/", getAllNotes) 24 | router.Post("/", createNote) 25 | router.Route("/{noteId}", func(router chi.Router) { 26 | router.Use(NoteContext) 27 | router.Get("/", getNote) 28 | router.Put("/", updateNote) 29 | router.Delete("/", deleteNote) 30 | }) 31 | } 32 | 33 | func NoteContext(next http.Handler) http.Handler { 34 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | noteId := chi.URLParam(r, "noteId") 36 | if noteId == "" { 37 | render.Render(w, r, ErrorRenderer(fmt.Errorf("note ID is required"))) 38 | return 39 | } 40 | id, err := strconv.Atoi(noteId) 41 | if err != nil { 42 | render.Render(w, r, ErrorRenderer(fmt.Errorf("invalid Note ID"))) 43 | return 44 | } 45 | ctx := context.WithValue(r.Context(), noteIdKey, id) 46 | next.ServeHTTP(w, r.WithContext(ctx)) 47 | }) 48 | } 49 | 50 | func createNote(w http.ResponseWriter, r *http.Request) { 51 | log.Printf("Create: %v", r) 52 | note := &models.Note{} 53 | if err := render.Bind(r, note); err != nil { 54 | render.Render(w, r, ErrBadRequest) 55 | return 56 | } 57 | if err := dbInstance.AddNote(note); err != nil { 58 | render.Render(w, r, ErrorRenderer(err)) 59 | return 60 | } 61 | if err := render.Render(w, r, note); err != nil { 62 | render.Render(w, r, ServerErrorRenderer(err)) 63 | return 64 | } 65 | } 66 | 67 | func getAllNotes(w http.ResponseWriter, r *http.Request) { 68 | notes, err := dbInstance.GetAllNotes() 69 | if err != nil { 70 | log.Printf("Error: %s", err.Error()) 71 | render.Render(w, r, ServerErrorRenderer(err)) 72 | return 73 | } 74 | if err := render.Render(w, r, notes); err != nil { 75 | render.Render(w, r, ErrorRenderer(err)) 76 | } 77 | } 78 | 79 | func getNote(w http.ResponseWriter, r *http.Request) { 80 | noteId := r.Context().Value(noteIdKey).(int) 81 | note, err := dbInstance.GetNoteById(noteId) 82 | if err != nil { 83 | if err == db.ErrRowNotFound { 84 | render.Render(w, r, ErrNotFound) 85 | } else { 86 | render.Render(w, r, ErrorRenderer(err)) 87 | } 88 | return 89 | } 90 | if err := render.Render(w, r, ¬e); err != nil { 91 | render.Render(w, r, ServerErrorRenderer(err)) 92 | return 93 | } 94 | } 95 | 96 | func deleteNote(w http.ResponseWriter, r *http.Request) { 97 | noteId := r.Context().Value(noteIdKey).(int) 98 | err := dbInstance.DeleteNote(noteId) 99 | if err != nil { 100 | if err == db.ErrRowNotFound { 101 | render.Render(w, r, ErrNotFound) 102 | } else { 103 | render.Render(w, r, ServerErrorRenderer(err)) 104 | } 105 | return 106 | } 107 | } 108 | 109 | func updateNote(w http.ResponseWriter, r *http.Request) { 110 | log.Printf("Update %v", r) 111 | noteId := r.Context().Value(noteIdKey).(int) 112 | noteData := models.Note{} 113 | if err := render.Bind(r, ¬eData); err != nil { 114 | render.Render(w, r, ErrBadRequest) 115 | return 116 | } 117 | item, err := dbInstance.UpdateNote(noteId, noteData) 118 | if err != nil { 119 | if err == db.ErrRowNotFound { 120 | render.Render(w, r, ErrNotFound) 121 | } else { 122 | render.Render(w, r, ServerErrorRenderer(err)) 123 | } 124 | return 125 | } 126 | if err := render.Render(w, r, &item); err != nil { 127 | render.Render(w, r, ServerErrorRenderer(err)) 128 | return 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /streamlit_app/src/views.py: -------------------------------------------------------------------------------- 1 | import streamlit as st 2 | 3 | from src.formatting import display_timestamp, utc_timestamp 4 | from src.models import Note, BaseNote 5 | from src.services import NoteService, CHAR_LIMIT 6 | 7 | 8 | def render_note(note: Note) -> None: 9 | """Show a note with streamlit display functions""" 10 | st.subheader(f"By {note.username} at {display_timestamp(note.created_timestamp)}") 11 | st.caption( 12 | f"Note #{note.rowid} -- Updated at {display_timestamp(note.updated_timestamp)}" 13 | ) 14 | st.write(note.body) 15 | 16 | 17 | def do_create(note: BaseNote) -> None: 18 | """Streamlit callback for creating a note and showing confirmation""" 19 | st.warning("Creating your Note") 20 | NoteService.create_note(note) 21 | st.success( 22 | f"Successfully Created your Note! Check the Read Note Feed page to see it" 23 | ) 24 | 25 | 26 | def render_create() -> None: 27 | """Show the form for creating a new Note""" 28 | with st.form("create_form", clear_on_submit=False): 29 | st.write("Enter a Username and Note") 30 | username = st.text_input( 31 | "Username", 32 | value="anonymous", 33 | max_chars=CHAR_LIMIT, 34 | help="Enter a Username to display by your note", 35 | ) 36 | note = st.text_input( 37 | "Note", 38 | value="Sample Note Input", 39 | max_chars=CHAR_LIMIT, 40 | help="Enter your note (valid html-safe Markdown)", 41 | ) 42 | 43 | new_note = BaseNote(utc_timestamp(), utc_timestamp(), username, note) 44 | submitted = st.form_submit_button( 45 | "Submit", help="Create your Note! (You'll get a confirmation below)" 46 | ) 47 | if submitted: 48 | do_create(new_note) 49 | 50 | 51 | def render_read() -> None: 52 | """Show all of the notes in the database in a feed""" 53 | st.success("Reading Note Feed") 54 | notes = NoteService.list_all_notes() 55 | with st.expander("Raw Note Table Data"): 56 | st.table(notes) 57 | 58 | for note in notes: 59 | render_note(note) 60 | 61 | 62 | def do_update(new_note: Note) -> None: 63 | """Streamlit callback for updating a note and showing confirmation""" 64 | st.warning(f"Updating Note #{new_note.rowid}") 65 | NoteService.update_note(new_note) 66 | st.success(f"Updated Note #{new_note.rowid}, go to the Read Notes Feed to see it!") 67 | 68 | 69 | def render_update() -> None: 70 | """Show the form for updating an existing Note""" 71 | st.success("Reading Notes") 72 | notes = NoteService.list_all_notes() 73 | note_map = {note.rowid: note for note in notes} 74 | note_id = st.selectbox( 75 | "Which Note to Update?", 76 | note_map.keys(), 77 | format_func=lambda x: f"{note_map[x].rowid} - by {note_map[x].username} on {display_timestamp(note_map[x].created_timestamp)}", 78 | ) 79 | note_to_update = note_map[note_id] 80 | with st.form("update_form"): 81 | st.write("Update Username and/or Note") 82 | username = st.text_input( 83 | "Username", 84 | value=note_to_update.username, 85 | max_chars=CHAR_LIMIT, 86 | help="Enter a Username to display by your note", 87 | ) 88 | body = st.text_input( 89 | "Note", 90 | value=note_to_update.body, 91 | max_chars=CHAR_LIMIT, 92 | help="Enter your note (valid html-safe Markdown)", 93 | ) 94 | 95 | st.caption( 96 | f"Note #{note_id} - by {note_to_update.username} on {display_timestamp(note_to_update.created_timestamp)}" 97 | ) 98 | 99 | submitted = st.form_submit_button( 100 | "Submit", 101 | help="This will change the body of the note, the username, or both. It also updates the updated at time.", 102 | ) 103 | if submitted: 104 | new_note = Note( 105 | note_to_update.created_timestamp, 106 | utc_timestamp(), 107 | username, 108 | body, 109 | note_to_update.rowid, 110 | ) 111 | do_update(new_note) 112 | 113 | 114 | def do_delete(note_to_delete: Note) -> None: 115 | """Streamlit callback for deleting a note and showing confirmation""" 116 | st.warning(f"Deleting Note #{note_to_delete.rowid}") 117 | NoteService.delete_note(note_to_delete) 118 | st.success(f"Deleted Note #{note_to_delete.rowid}") 119 | 120 | 121 | def render_delete() -> None: 122 | """Show the form for deleting an existing Note""" 123 | st.success("Reading Notes") 124 | notes = NoteService.list_all_notes() 125 | note_map = {note.rowid: note for note in notes} 126 | note_id = st.selectbox("Which Note to Delete?", note_map.keys()) 127 | note_to_delete = note_map[note_id] 128 | 129 | render_note(note_to_delete) 130 | 131 | st.button( 132 | "Delete Note (This Can't Be Undone!)", 133 | help="I hope you know what you're getting into!", 134 | on_click=do_delete, 135 | args=(note_to_delete,), 136 | ) 137 | 138 | 139 | def render_about(*_) -> None: 140 | """Show App info""" 141 | st.write("""\ 142 | # Streamlit App Demo 143 | 144 | Howdy :wave:! 145 | Welcome to my Streamlit Full Stack App exploration. 146 | 147 | This started as the Littlest Fullstack App with just Streamlit + SQLite. 148 | 149 | Next steps were upgrading the data store to Postgres :elephant:. 150 | 151 | Then an NGINX webserver + Docker containerization layer to serve it all up! 152 | 153 | Finally a backend REST API layer with Go!""") 154 | -------------------------------------------------------------------------------- /streamlit_app/.streamlit/config.example.toml: -------------------------------------------------------------------------------- 1 | # Below are all the sections and options you can have in ~/.streamlit/config.toml. 2 | 3 | [global] 4 | 5 | # By default, Streamlit checks if the Python watchdog module is available and, if not, prints a warning asking for you to install it. The watchdog module is not required, but highly recommended. It improves Streamlit's ability to detect changes to files in your filesystem. 6 | # If you'd like to turn off this warning, set this to True. 7 | # Default: false 8 | disableWatchdogWarning = false 9 | 10 | # If True, will show a warning when you run a Streamlit-enabled script via "python my_script.py". 11 | # Default: true 12 | showWarningOnDirectExecution = true 13 | 14 | # DataFrame serialization. 15 | # Acceptable values: - 'legacy': Serialize DataFrames using Streamlit's custom format. Slow but battle-tested. - 'arrow': Serialize DataFrames using Apache Arrow. Much faster and versatile. 16 | # Default: "arrow" 17 | dataFrameSerialization = "arrow" 18 | 19 | 20 | [logger] 21 | 22 | # Level of logging: 'error', 'warning', 'info', or 'debug'. 23 | # Default: 'info' 24 | level = "info" 25 | 26 | # String format for logging messages. If logger.datetimeFormat is set, logger messages will default to `%(asctime)s.%(msecs)03d %(message)s`. See [Python's documentation](https://docs.python.org/2.6/library/logging.html#formatter-objects) for available attributes. 27 | # Default: None 28 | messageFormat = "%(asctime)s %(message)s" 29 | 30 | 31 | [client] 32 | 33 | # Whether to enable st.cache. 34 | # Default: true 35 | caching = true 36 | 37 | # If false, makes your Streamlit script not draw to a Streamlit app. 38 | # Default: true 39 | displayEnabled = true 40 | 41 | # Controls whether uncaught app exceptions are displayed in the browser. By default, this is set to True and Streamlit displays app exceptions and associated tracebacks in the browser. 42 | # If set to False, an exception will result in a generic message being shown in the browser, and exceptions and tracebacks will be printed to the console only. 43 | # Default: true 44 | showErrorDetails = false 45 | 46 | 47 | [runner] 48 | 49 | # Allows you to type a variable or string by itself in a single line of Python code to write it to the app. 50 | # Default: true 51 | magicEnabled = false 52 | 53 | # Install a Python tracer to allow you to stop or pause your script at any point and introspect it. As a side-effect, this slows down your script's execution. 54 | # Default: false 55 | installTracer = false 56 | 57 | # Sets the MPLBACKEND environment variable to Agg inside Streamlit to prevent Python crashing. 58 | # Default: true 59 | fixMatplotlib = true 60 | 61 | # Run the Python Garbage Collector after each script execution. This can help avoid excess memory use in Streamlit apps, but could introduce delay in rerunning the app script for high-memory-use applications. 62 | # Default: true 63 | postScriptGC = true 64 | 65 | 66 | [server] 67 | 68 | # List of folders that should not be watched for changes. This impacts both "Run on Save" and @st.cache. 69 | # Relative paths will be taken as relative to the current working directory. 70 | # Example: ['/home/user1/env', 'relative/path/to/folder'] 71 | # Default: [] 72 | folderWatchBlacklist = [] 73 | 74 | # Change the type of file watcher used by Streamlit, or turn it off completely. 75 | # Allowed values: * "auto" : Streamlit will attempt to use the watchdog module, and falls back to polling if watchdog is not available. * "watchdog" : Force Streamlit to use the watchdog module. * "poll" : Force Streamlit to always use polling. * "none" : Streamlit will not watch files. 76 | # Default: "auto" 77 | fileWatcherType = "auto" 78 | 79 | # Symmetric key used to produce signed cookies. If deploying on multiple replicas, this should be set to the same value across all replicas to ensure they all share the same secret. 80 | # Default: randomly generated secret key. 81 | cookieSecret = "changemecookiesecret" 82 | 83 | # If false, will attempt to open a browser window on start. 84 | # Default: false unless (1) we are on a Linux box where DISPLAY is unset, or (2) we are running in the Streamlit Atom plugin. 85 | headless = true 86 | 87 | # Automatically rerun script when the file is modified on disk. 88 | # Default: false 89 | runOnSave = true 90 | 91 | # The address where the server will listen for client and browser connections. Use this if you want to bind the server to a specific address. If set, the server will only be accessible from this address, and not from any aliases (like localhost). 92 | # Default: (unset) 93 | #address = 94 | 95 | # The port where the server will listen for browser connections. 96 | # Default: 8501 97 | port = 8501 98 | 99 | # The base path for the URL where Streamlit should be served from. 100 | # Default: "" 101 | baseUrlPath = "" 102 | 103 | # Enables support for Cross-Origin Request Sharing (CORS) protection, for added security. 104 | # Due to conflicts between CORS and XSRF, if `server.enableXsrfProtection` is on and `server.enableCORS` is off at the same time, we will prioritize `server.enableXsrfProtection`. 105 | # Default: true 106 | enableCORS = true 107 | 108 | # Enables support for Cross-Site Request Forgery (XSRF) protection, for added security. 109 | # Due to conflicts between CORS and XSRF, if `server.enableXsrfProtection` is on and `server.enableCORS` is off at the same time, we will prioritize `server.enableXsrfProtection`. 110 | # Default: true 111 | enableXsrfProtection = true 112 | 113 | # Max size, in megabytes, for files uploaded with the file_uploader. 114 | # Default: 200 115 | maxUploadSize = 200 116 | 117 | # Max size, in megabytes, of messages that can be sent via the WebSocket connection. 118 | # Default: 200 119 | maxMessageSize = 200 120 | 121 | # Enables support for websocket compression. 122 | # Default: true 123 | enableWebsocketCompression = true 124 | 125 | 126 | [browser] 127 | 128 | # Internet address where users should point their browsers in order to connect to the app. Can be IP address or DNS name and path. 129 | # This is used to: - Set the correct URL for CORS and XSRF protection purposes. - Show the URL on the terminal - Open the browser 130 | # Default: 'localhost' 131 | serverAddress = "streamlit" 132 | 133 | # Whether to send usage statistics to Streamlit. 134 | # Default: true 135 | gatherUsageStats = false 136 | 137 | # Port where users should point their browsers in order to connect to the app. 138 | # This is used to: - Set the correct URL for CORS and XSRF protection purposes. - Show the URL on the terminal - Open the browser 139 | # Default: whatever value is set in server.port. 140 | serverPort = 8501 141 | 142 | 143 | [ui] 144 | 145 | # Flag to hide most of the UI elements found at the top of a Streamlit app. 146 | # NOTE: This does *not* hide the hamburger menu in the top-right of an app. 147 | # Default: false 148 | hideTopBar = false 149 | 150 | 151 | [mapbox] 152 | 153 | # Configure Streamlit to use a custom Mapbox token for elements like st.pydeck_chart and st.map. To get a token for yourself, create an account at https://mapbox.com. It's free (for moderate usage levels)! 154 | # Default: "" 155 | token = "" 156 | 157 | 158 | [deprecation] 159 | 160 | # Set to false to disable the deprecation warning for the file uploader encoding. 161 | # Default: true 162 | showfileUploaderEncoding = true 163 | 164 | # Set to false to disable the deprecation warning for using the global pyplot instance. 165 | # Default: true 166 | showPyplotGlobalUse = true 167 | 168 | 169 | [theme] 170 | 171 | # The preset Streamlit theme that your custom theme inherits from. One of "light" or "dark". 172 | #base = 173 | 174 | # Primary accent color for interactive elements. 175 | #primaryColor = 176 | 177 | # Background color for the main content area. 178 | #backgroundColor = 179 | 180 | # Background color used for the sidebar and most interactive widgets. 181 | #secondaryBackgroundColor = 182 | 183 | # Color used for almost all text. 184 | #textColor = 185 | 186 | # Font family for all text in the app, except code blocks. One of "sans serif", "serif", or "monospace". 187 | #font = 188 | 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Streamlit Full Stack App 2 | 3 | [](https://share.streamlit.io/gerardrbentley/streamlit-fullstack/app.py) 4 | 5 | - Postgres Version Live at [streamlit-postgres.gerardbentley.com](https://streamlit-postgres.gerardbentley.com) 6 | 7 | Demo Repo on building a Full Stack CRUD App with Streamlit with multiple levels of complexity. 8 | 9 | Create, Read, Update, and Delete from a feed of 140 character markdown notes. 10 | 11 | Run a single Streamlit server with SQLite Database, a Streamlit + Postgres + Nginx Docker-Compose stack, or a full Streamlit + Go + Postgres + Nginx stack 12 | 13 | - :mouse: littlest (original) version wil remain on [branch `littlest`](https://github.com/gerardrbentley/streamlit-fullstack/tree/littlest) 14 | - :elephant: Postgres (`psycopg3`) + Nginx + Docker-Compose version at [branch `psycopg`](https://github.com/gerardrbentley/streamlit-fullstack/tree/psycopg) and Live at [streamlit-postgres.gerardbentley.com](https://streamlit-postgres.gerardbentley.com) 15 | - Go REST API + Postgres + Nginx + Docker-Compose version at [branch `go`](https://github.com/gerardrbentley/streamlit-fullstack/tree/go) 16 | 17 | ## Run Streamlit + Go + Postgres + Nginx Version 18 | 19 | For when that SQLite database crumbles and Backend needs get complex. 20 | 21 | ```sh 22 | curl https://github.com/gerardrbentley/streamlit-fullstack/archive/refs/heads/go.zip -O -L 23 | unzip go 24 | cd streamlit-fullstack-go 25 | cp example.env .env.dev 26 | # Production: Fill out .env with real credentials, docker compose should shut off streamlit ports 27 | cp streamlit_app/.streamlit/config.example.toml streamlit_app/.streamlit/config.toml 28 | # Production: random cookie secret 29 | # python -c "from pathlib import Path; from string import ascii_lowercase, digits; from random import choice; Path('streamlit_app/.streamlit/config.toml').write_text(Path('streamlit_app/.streamlit/config.example.toml').read_text().replace('changemecookiesecret', ''.join([choice(ascii_lowercase + digits) for _ in range(64)])))" 30 | docker-compose up 31 | # Will take some time to download all layers and dependencies 32 | ``` 33 | 34 | *NOTE:* Any changes to Go server require a new build of that container and restart. (or just kill the compose stack and `up --build` for the lazy) 35 | 36 | Go backend server relies on [go-chi](https://go-chi.io/#/) as the routing layer. 37 | 38 | Database connection relies on [lib/pq](https://github.com/lib/pq) to communicate with postgres. 39 | 40 | ## Run Streamlit + Postgres + Nginx Version 41 | 42 | Ran with `Docker version 20.10.12`, `Docker Compose version v2.2.3`: 43 | 44 | ```sh 45 | curl https://github.com/gerardrbentley/streamlit-fullstack/archive/refs/heads/psycopg.zip -O -L 46 | unzip psycopg 47 | cd streamlit-fullstack-psycopg 48 | cp example.env .env.dev 49 | # Production: Fill out .env with real credentials, docker compose should shut off streamlit ports 50 | cp streamlit_app/.streamlit/config.example.toml streamlit_app/.streamlit/config.toml 51 | # Production: random cookie secret 52 | # python -c "from pathlib import Path; from string import ascii_lowercase, digits; from random import choice; Path('streamlit_app/.streamlit/config.toml').write_text(Path('streamlit_app/.streamlit/config.example.toml').read_text().replace('changemecookiesecret', ''.join([choice(ascii_lowercase + digits) for _ in range(64)])))" 53 | docker-compose up 54 | # Will take some time to download all layers and python requirements 55 | ``` 56 | 57 | *Notes:* 58 | 59 | - Use `--build` with docker-compose to rebuild image after changing dependencies / dockerfile. 60 | - Use `-d` with docker-compose to detach from terminal output (remember to `docker-compose down` when you want it to stop) 61 | - Use `docker-compose down --volumes` to wipe database (docker volume) 62 | 63 | ## Run Streamlit w/ SQLite Version 64 | 65 | ```sh 66 | curl https://raw.githubusercontent.com/gerardrbentley/streamlit-fullstack/littlest/app.py -O 67 | pip install streamlit 68 | streamlit run app.py 69 | ``` 70 | 71 | (Don't have Python / `pip` installed? [here's my way](https://tech.gerardbentley.com/python/beginner/2022/01/29/install-python.html)) 72 | 73 | ## The Littlest Full Stack App 74 | 75 | The idea for this was starting with built-in sqlite module and streamlit to build a full stack application in a single Python file: 76 | 77 | - Streamlit: 78 | - Frontend 79 | - Backend 80 | - SQLite 81 | - Data Store 82 | 83 | Obviously this takes some liberties with the definition of Full-Stack App, for my purposes I take it to mean "a web application with a frontend that receives data upon request to a backend and that data is persisted in some data store" 84 | 85 | For the first swing at this I also took the standard CRUD definition of Full-Stack: 86 | 87 | - Create 88 | - Read 89 | - Update 90 | - Delete 91 | 92 | ### Data Store 93 | 94 | #### Postgres 95 | 96 | The Postgres server in dev and when used in docker-compose stack is dockerized. 97 | 98 | It spins up as a service that supports the streamlit app. 99 | The Streamlit startup sequence naively createst the notes table if it doesn't exist, and trys to seed a row. 100 | (This action is also cached by Streamlit, so itsn't terrible, but definitely not [12 factor app](https://12factor.net/admin-processes) standard for admin process) 101 | 102 | This version uses synchronous `psycopg` v3 to read and write similarly to the SQLite statements, with the bonus of reading rows directly from the database into our `dataclass` Note model. 103 | 104 | A `pydantic.BaseSettings` object grabs the postgres connection variables from environment, which are provided by docker-compose (*note* EXPORT env variables if you have your own postgres server) 105 | 106 | ```python 107 | 108 | class PsycopgSettings(BaseSettings): 109 | """\ 110 | host: 111 | Name of host to connect to. If this begins with a slash, it specifies Unix-domain communication rather than TCP/IP communication; the value is the name of the directory in which the socket file is stored. The default behavior when host is not specified is to connect to a Unix-domain socket in /tmp (or whatever socket directory was specified when PostgreSQL was built). On machines without Unix-domain sockets, the default is to connect to localhost. 112 | 113 | port: 114 | Port number to connect to at the server host, or socket file name extension for Unix-domain connections. 115 | 116 | dbname: 117 | The database name. Defaults to be the same as the user name. 118 | 119 | user: 120 | PostgreSQL user name to connect as. Defaults to be the same as the operating system name of the user running the application. 121 | 122 | password: 123 | Password to be used if the server demands password authentication. 124 | """ 125 | 126 | postgres_host: str 127 | postgres_port: int = 5432 128 | postgres_db: str 129 | postgres_user: str 130 | postgres_password: str 131 | 132 | def get_connection_string(self) -> str: 133 | return f"dbname={self.postgres_db} host={self.postgres_host} user={self.postgres_user} password={self.postgres_password}" 134 | 135 | def get_connection_args(self) -> dict: 136 | return { 137 | "host": self.postgres_host, 138 | "port": self.postgres_port, 139 | "dbname": self.postgres_db, 140 | "user": self.postgres_user, 141 | "password": self.postgres_password, 142 | } 143 | ``` 144 | 145 | 146 | #### SQLite (littlest version) 147 | 148 | Using SQLite is straightforward if you understand how to set up and query other SQL flavors. 149 | 150 | I say this because we don't need to download or spin up any external database server, its a C library that will let us interact with a database with just two lines of python! 151 | 152 | ```python 153 | import sqlite3 154 | connection = sqlite3.connect(':memory') 155 | ``` 156 | 157 | This gets us a `Connection` object for interacting with an in-memory SQL database! 158 | 159 | For the purposes of using it as a more persistant store, it can be configured to write to a local file (conventionally ending with `.db`). 160 | 161 | It also defaults to only being accessible by a single thread, so we'll need to turn this off for multiple users 162 | 163 | ```python 164 | connection = sqlite3.connect('notes.db', check_same_thread=False) 165 | ``` 166 | 167 | ### Backend 168 | 169 | #### Psycopg 170 | 171 | Version 3 of `psycopg` has some static typing benefits built in. 172 | Here we had a function that executes a query then returns all the matching / fetched rows. 173 | 174 | The nice thing is if we pass a dataclass type as the `dclass` arg we'll get out a list of that type! 175 | 176 | We also have to fix the execute query function that doesn't return any rows, as psycopg will raise an Exception. 177 | 178 | ```python 179 | def fetch_rows( 180 | connection: psycopg.Connection, 181 | query: str, 182 | args: Optional[dict] = None, 183 | dclass: Optional[Type] = None, 184 | ) -> list: 185 | """Given psycopg.Connection and a string query (and optionally necessary query args as a dict), 186 | Attempt to execute query with cursor, commit transaction, and return fetched rows""" 187 | if dclass is not None: 188 | cur = connection.cursor(row_factory=class_row(dclass)) 189 | else: 190 | cur = connection.cursor() 191 | if args is not None: 192 | cur.execute(query, args) 193 | else: 194 | cur.execute(query) 195 | results = cur.fetchall() 196 | cur.close() 197 | return results 198 | 199 | 200 | def execute_query( 201 | connection: psycopg.Connection, 202 | query: str, 203 | args: Optional[dict] = None, 204 | ) -> None: 205 | """Given psycopg.Connection and a string query (and optionally necessary query args as a dict), 206 | Attempt to execute query with cursor""" 207 | cur = connection.cursor() 208 | if args is not None: 209 | cur.execute(query, args) 210 | else: 211 | cur.execute(query) 212 | cur.close() 213 | ``` 214 | 215 | #### SQLite 216 | 217 | This is all in one file, but the idea of a "Service" that provides access to the data store and returns rows from the data store can be captured in a class as a namespace: 218 | 219 | ```python 220 | class NoteService: 221 | """Namespace for Database Related Note Operations""" 222 | 223 | def list_all_notes( 224 | connection: sqlite3.Connection, 225 | ) -> List[sqlite3.Row]: 226 | """Returns rows from all notes. Ordered in reverse creation order""" 227 | read_notes_query = f"""SELECT rowid, created_timestamp, updated_timestamp, username, body 228 | FROM notes ORDER BY rowid DESC;""" 229 | note_rows = execute_query(connection, read_notes_query) 230 | return note_rows 231 | 232 | def create_note(connection: sqlite3.Connection, note: BaseNote) -> None: 233 | """Create a Note in the database""" 234 | create_note_query = f"""INSERT into notes(created_timestamp, updated_timestamp, username, body) 235 | VALUES(:created_timestamp, :updated_timestamp, :username, :body);""" 236 | execute_query(connection, create_note_query, asdict(note)) 237 | 238 | def update_note(connection: sqlite3.Connection, note: Note) -> None: 239 | """Replace a Note in the database""" 240 | update_note_query = f"""UPDATE notes SET updated_timestamp=:updated_timestamp, username=:username, body=:body WHERE rowid=:rowid;""" 241 | execute_query(connection, update_note_query, asdict(note)) 242 | 243 | def delete_note(connection: sqlite3.Connection, note: Note) -> None: 244 | """Delete a Note in the database""" 245 | delete_note_query = f"""DELETE from notes WHERE rowid = :rowid;""" 246 | execute_query(connection, delete_note_query, {"rowid": note.rowid}) 247 | ``` 248 | 249 | ### Frontend 250 | 251 | #### Updated 252 | 253 | The main `app.py` entrypoint now contains less code, having been split into `db.py`, `data.py`, `views.py`, and `formatting.py`. 254 | 255 | It now focuses on the setup, connecting to the database, and rendering the selected page view 256 | 257 | ```python 258 | import src.views as views 259 | from src.db import PsycopgSettings, create_notes_table, seed_notes_table 260 | 261 | PAGES = { 262 | "Read Note Feed": views.render_read, # Read first for display default 263 | "Create a Note": views.render_create, 264 | "Update a Note": views.render_update, 265 | "Delete a Note": views.render_delete, 266 | "About": views.render_about, 267 | } 268 | 269 | def main() -> None: 270 | """Main Streamlit App Entry""" 271 | connection_args = PsycopgSettings().get_connection_args() 272 | connection = get_connection(**connection_args) 273 | init_db(connection) 274 | 275 | st.header(f"The Littlest Fullstack App + Postgres :elephant:!") 276 | render_sidebar(connection) 277 | 278 | 279 | def render_sidebar(connection: psycopg.Connection) -> None: 280 | """Provides Selectbox Drop Down for which view to render""" 281 | choice = st.sidebar.radio("Go To Page:", PAGES.keys()) 282 | render_func = PAGES.get(choice) 283 | render_func(connection) 284 | ``` 285 | 286 | #### Littlest 287 | 288 | I chose to use a Selectbox in the Sidebar to act as page navigation. 289 | This organizes things similarly to other Streamlit multi page examples. 290 | 291 | The main entrypoint looks like this: 292 | 293 | ```python 294 | def main() -> None: 295 | """Main Streamlit App Entry""" 296 | connection = get_connection(DATABASE_URI) 297 | init_db(connection) 298 | 299 | st.header(f"The Littlest Fullstack App!") 300 | render_sidebar(connection) 301 | 302 | 303 | def render_sidebar(connection: sqlite3.Connection) -> None: 304 | """Provides Selectbox Drop Down for which view to render""" 305 | views = { 306 | "Read Note Feed": render_read, # Read first for display default 307 | "Create a Note": render_create, 308 | "Update a Note": render_update, 309 | "Delete a Note": render_delete, 310 | "About": render_about, 311 | } 312 | choice = st.sidebar.selectbox("Menu", views.keys()) 313 | render_func = views.get(choice) 314 | render_func(connection) 315 | ``` 316 | 317 | Each of those `render_xyz` functions will use `st.` functions to display in the main body of the page when it is chosen in the SelectBox / drop down. 318 | 319 | This is the `render_read` for example: 320 | 321 | ```python 322 | def render_note(note: Note) -> None: 323 | """Show a note with streamlit display functions""" 324 | st.subheader(f"By {note.username} at {display_timestamp(note.created_timestamp)}") 325 | st.caption( 326 | f"Note #{note.rowid} -- Updated at {display_timestamp(note.updated_timestamp)}" 327 | ) 328 | st.write(note.body) 329 | 330 | 331 | def render_read(connection: sqlite3.Connection) -> None: 332 | """Show all of the notes in the database in a feed""" 333 | st.success("Reading Note Feed") 334 | note_rows = NoteService.list_all_notes(connection) 335 | with st.expander("Raw Note Table Data"): 336 | st.table(note_rows) 337 | 338 | notes = [Note(**row) for row in note_rows] 339 | for note in notes: 340 | render_note(note) 341 | ``` 342 | 343 | For more on the forms for Creating, Updating and Deleting, check out the source code on github. 344 | 345 | ### Gluing It All Together 346 | 347 | - SQLite can run with the Python process, so we're good to deploy it wherever the Streamlit app runs 348 | - Frontend and Backend are in one server, so there's no JSON or RPC data going between App services 349 | 350 | Python `dataclasses.dataclass` provides a nice way of modeling simple entities like this Note example. 351 | It lacks all of the features of `pydantic` and `attrs`, but it does have a free `__init__` with kwargs and the `dataclasses.asdict` method. 352 | 353 | After the rows are read from the database, the data is passed into this `dataclass` Note model. 354 | The model provides some level of validation on the data types and a Python object with known attributes for type-hinting and checking. 355 | 356 | ```python 357 | @dataclass 358 | class BaseNote: 359 | """Note Entity for Creation / Handling without database ID""" 360 | 361 | created_timestamp: int 362 | updated_timestamp: int 363 | username: str 364 | body: str 365 | 366 | 367 | @dataclass 368 | class Note(BaseNote): 369 | """Note Entity to model database entry""" 370 | 371 | rowid: int 372 | ``` 373 | --------------------------------------------------------------------------------