diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..b6ff5db --- /dev/null +++ b/client/client.go @@ -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, ¬es); 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, ¬e); 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, ¬e); 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, ¬e); 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) +} diff --git a/main.go b/main.go index 2ca0d5e..ca32331 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,107 @@ package main 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() { - 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) + } } diff --git a/server/server.go b/server/server.go index 8ccadff..6832d16 100644 --- a/server/server.go +++ b/server/server.go @@ -1,34 +1,40 @@ package server import ( + "encoding/json" "fmt" + "log" "net/http" "strconv" + "time" "git.hrafn.xyz/aether/notes/internal/repository" ) type Server struct { 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() server := Server{ - httpServer: &http.Server{Handler: mux}, + httpServer: &http.Server{Handler: loggingMiddleware(mux, logger)}, noteRepo: repo, + logger: logger, } mux.HandleFunc("GET /notes", server.getNotes) mux.HandleFunc("POST /notes", server.createNote) - mux.HandleFunc("GET /notes/{id}", server.getNoteByID) - mux.HandleFunc("PUT /notes/{id}", server.updateNoteByID) - mux.HandleFunc("DELETE /notes/{id}", server.deleteNoteByID) + mux.HandleFunc("GET /note/{id}", server.getNoteByID) + mux.HandleFunc("PUT /note/{id}", server.updateNoteByID) + mux.HandleFunc("DELETE /note/{id}", server.deleteNoteByID) return server } func (s *Server) Start(addr string) error { s.httpServer.Addr = addr + s.logger.Printf("Starting server on %s", addr) return s.httpServer.ListenAndServe() } @@ -36,9 +42,31 @@ func (s *Server) Stop() error { 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) { 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) { @@ -58,7 +86,10 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request) { note := struct { Content string `json:"content"` }{} - // Decode JSON body into note struct + if err := json.NewDecoder(r.Body).Decode(¬e); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } createdNote, err := s.noteRepo.CreateNote(r.Context(), note.Content) if err != nil { s.getErrorResponse(w, err) @@ -92,7 +123,10 @@ func (s *Server) updateNoteByID(w http.ResponseWriter, r *http.Request) { note := struct { Content string `json:"content"` }{} - // Decode JSON body into note struct + if err := json.NewDecoder(r.Body).Decode(¬e); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } updatedNote, err := s.noteRepo.UpdateNote(r.Context(), id, note.Content) if err != nil { s.getErrorResponse(w, err)