Implement client and server

This commit is contained in:
Micheal Wilkinson
2026-03-18 00:32:14 +00:00
parent 922b136c72
commit bc291a6941
3 changed files with 273 additions and 11 deletions

130
client/client.go Normal file
View File

@@ -0,0 +1,130 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
const baseURL = "http://localhost:8080"
var httpClient = &http.Client{}
func doRequest(method, url string, body any) []byte {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
log.Fatalf("Failed to marshal request body: %v", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
log.Fatalf("Failed to create request: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient.Do(req)
if err != nil {
log.Fatalf("Failed to send request: %v", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to read response body: %v", err)
}
return respBody
}
func ListNotes() {
body := doRequest(http.MethodGet, baseURL+"/notes", nil)
var notes []struct {
ID int `json:"id"`
Content string `json:"content"`
LastUpdate time.Time `json:"last_update"`
}
if err := json.Unmarshal(body, &notes); err != nil {
log.Fatalf("Failed to parse notes: %v", err)
}
if len(notes) == 0 {
fmt.Println("No notes found.")
return
}
// Calculate column widths from headers and content
idW, updatedW, contentW := 2, 7, 7 // minimums: "ID", "Updated", "Content"
for _, n := range notes {
if w := len(fmt.Sprintf("%d", n.ID)); w > idW {
idW = w
}
if w := len(n.LastUpdate.Format("2006-01-02 15:04:05")); w > updatedW {
updatedW = w
}
if w := len(n.Content); w > contentW {
contentW = w
}
}
row := func(id, updated, content string) {
fmt.Printf("| %-*s | %-*s | %-*s |\n", idW, id, updatedW, updated, contentW, content)
}
sep := fmt.Sprintf("|-%s-|-%s-|-%s-|", strings.Repeat("-", idW), strings.Repeat("-", updatedW), strings.Repeat("-", contentW))
row("ID", "Updated", "Content")
fmt.Println(sep)
for _, n := range notes {
row(fmt.Sprintf("%d", n.ID), n.LastUpdate.Format("2006-01-02 15:04:05"), n.Content)
}
}
func CreateNote(content string) {
body := doRequest(http.MethodPost, baseURL+"/notes", map[string]string{"content": content})
var note struct {
ID int `json:"id"`
Content string `json:"content"`
}
if err := json.Unmarshal(body, &note); err != nil {
log.Fatalf("Failed to parse note: %v", err)
}
fmt.Printf("Created note #%d: %s\n", note.ID, note.Content)
}
func GetNoteByID(id int) {
body := doRequest(http.MethodGet, fmt.Sprintf("%s/note/%d", baseURL, id), nil)
var note struct {
ID int `json:"id"`
Content string `json:"content"`
LastUpdate time.Time `json:"last_update"`
}
if err := json.Unmarshal(body, &note); err != nil {
log.Fatalf("Failed to parse note: %v", err)
}
fmt.Printf("Note #%d (updated %s):\n %s\n", note.ID, note.LastUpdate.Format("2006-01-02 15:04:05"), note.Content)
}
func UpdateNoteByID(id int, content string) {
body := doRequest(http.MethodPut, fmt.Sprintf("%s/note/%d", baseURL, id), map[string]string{"content": content})
var note struct {
ID int `json:"id"`
Content string `json:"content"`
}
if err := json.Unmarshal(body, &note); err != nil {
log.Fatalf("Failed to parse note: %v", err)
}
fmt.Printf("Updated note #%d: %s\n", note.ID, note.Content)
}
func DeleteNoteByID(id int) {
doRequest(http.MethodDelete, fmt.Sprintf("%s/note/%d", baseURL, id), nil)
fmt.Printf("Deleted note #%d\n", id)
}

102
main.go
View File

@@ -1,9 +1,107 @@
package main package main
import ( import (
"fmt" "context"
"flag"
"log"
"os"
"path/filepath"
"git.hrafn.xyz/aether/notes/client"
"git.hrafn.xyz/aether/notes/internal/repository"
"git.hrafn.xyz/aether/notes/internal/store/sqlite"
"git.hrafn.xyz/aether/notes/server"
) )
func main() { func main() {
fmt.Println("Hello, World!") serverMode := flag.Bool("server", false, "Start the application in server mode")
list := flag.Bool("list", false, "List all notes in the database")
create := flag.Bool("create", false, "Create a new note")
update := flag.Bool("update", false, "Update an existing note")
delete := flag.Bool("delete", false, "Delete a note from the database")
get := flag.Bool("get", false, "Get a note by ID")
databasefile := flag.String("db", filepath.Join(os.Getenv("HOME"), "notes.db"), "Path to the SQLite database file (only used in server mode)")
id := flag.Int("id", 0, "ID of the note to get, update, or delete (only used with -get, -update, or -delete)")
content := flag.String("content", "", "Content of the note to create or update (only used with -create or -update)")
flag.Parse()
modes := map[string]bool{
"server": *serverMode,
"list": *list,
"create": *create,
"update": *update,
"delete": *delete,
}
enabledCount := 0
for _, enabled := range modes {
if enabled {
enabledCount++
}
}
if enabledCount == 0 {
log.Fatal("No mode specified. Use -server, -list, -create, -update, -delete, or -get.")
os.Exit(1)
}
if enabledCount > 1 {
log.Fatal("Multiple modes specified. Please specify only one mode at a time.")
os.Exit(1)
}
if *serverMode {
dbPath := *databasefile
sqliteStore, err := sqlite.NewSQLiteStore(context.Background(), dbPath)
if err != nil {
panic(err)
}
defer sqliteStore.Close()
repo := repository.NewNoteRepository(sqliteStore)
s := server.GetServer(repo, log.Default())
err = s.Start(":8080")
if err != nil {
panic(err)
}
}
if *list {
client.ListNotes()
}
if *create {
if *content == "" {
log.Fatal("Content must be provided with -create")
os.Exit(1)
}
client.CreateNote(*content)
}
if *get {
if *id == 0 {
log.Fatal("ID must be provided with -get")
os.Exit(1)
}
client.GetNoteByID(*id)
}
if *update {
if *id == 0 {
log.Fatal("ID must be provided with -update")
os.Exit(1)
}
if *content == "" {
log.Fatal("Content must be provided with -update")
os.Exit(1)
}
client.UpdateNoteByID(*id, *content)
}
if *delete {
if *id == 0 {
log.Fatal("ID must be provided with -delete")
os.Exit(1)
}
client.DeleteNoteByID(*id)
os.Exit(1)
}
} }

View File

@@ -1,34 +1,40 @@
package server package server
import ( import (
"encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.hrafn.xyz/aether/notes/internal/repository" "git.hrafn.xyz/aether/notes/internal/repository"
) )
type Server struct { type Server struct {
httpServer *http.Server httpServer *http.Server
noteRepo repository.NoteRepository noteRepo *repository.NoteRepository
logger *log.Logger
} }
func GetServer(repo repository.NoteRepository) Server { func GetServer(repo *repository.NoteRepository, logger *log.Logger) Server {
mux := http.NewServeMux() mux := http.NewServeMux()
server := Server{ server := Server{
httpServer: &http.Server{Handler: mux}, httpServer: &http.Server{Handler: loggingMiddleware(mux, logger)},
noteRepo: repo, noteRepo: repo,
logger: logger,
} }
mux.HandleFunc("GET /notes", server.getNotes) mux.HandleFunc("GET /notes", server.getNotes)
mux.HandleFunc("POST /notes", server.createNote) mux.HandleFunc("POST /notes", server.createNote)
mux.HandleFunc("GET /notes/{id}", server.getNoteByID) mux.HandleFunc("GET /note/{id}", server.getNoteByID)
mux.HandleFunc("PUT /notes/{id}", server.updateNoteByID) mux.HandleFunc("PUT /note/{id}", server.updateNoteByID)
mux.HandleFunc("DELETE /notes/{id}", server.deleteNoteByID) mux.HandleFunc("DELETE /note/{id}", server.deleteNoteByID)
return server return server
} }
func (s *Server) Start(addr string) error { func (s *Server) Start(addr string) error {
s.httpServer.Addr = addr s.httpServer.Addr = addr
s.logger.Printf("Starting server on %s", addr)
return s.httpServer.ListenAndServe() return s.httpServer.ListenAndServe()
} }
@@ -36,9 +42,31 @@ func (s *Server) Stop() error {
return s.httpServer.Close() return s.httpServer.Close()
} }
func loggingMiddleware(next http.Handler, logger *log.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
logger.Printf("%s %s - %d (%dms)", r.Method, r.RequestURI, wrapped.statusCode, duration.Milliseconds())
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (s *Server) getJsonResponse(w http.ResponseWriter, data any) { func (s *Server) getJsonResponse(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Encode data to JSON and write to response if err := json.NewEncoder(w).Encode(data); err != nil {
s.logger.Printf("failed to encode response: %v", err)
}
} }
func (s *Server) getErrorResponse(w http.ResponseWriter, err error) { func (s *Server) getErrorResponse(w http.ResponseWriter, err error) {
@@ -58,7 +86,10 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request) {
note := struct { note := struct {
Content string `json:"content"` Content string `json:"content"`
}{} }{}
// Decode JSON body into note struct if err := json.NewDecoder(r.Body).Decode(&note); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
createdNote, err := s.noteRepo.CreateNote(r.Context(), note.Content) createdNote, err := s.noteRepo.CreateNote(r.Context(), note.Content)
if err != nil { if err != nil {
s.getErrorResponse(w, err) s.getErrorResponse(w, err)
@@ -92,7 +123,10 @@ func (s *Server) updateNoteByID(w http.ResponseWriter, r *http.Request) {
note := struct { note := struct {
Content string `json:"content"` Content string `json:"content"`
}{} }{}
// Decode JSON body into note struct if err := json.NewDecoder(r.Body).Decode(&note); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
updatedNote, err := s.noteRepo.UpdateNote(r.Context(), id, note.Content) updatedNote, err := s.noteRepo.UpdateNote(r.Context(), id, note.Content)
if err != nil { if err != nil {
s.getErrorResponse(w, err) s.getErrorResponse(w, err)