From a074f5a854277e23c1416ad5c667c89c7258056b Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Fri, 6 Feb 2026 21:23:59 -0500 Subject: [PATCH] feat(api): add file listing endpoint - Add GET /api endpoint to list all markdown files - Filter to only include .md files - Return JSON response with files array - Add comprehensive tests for file listing functionality --- IMPLEMENTATION_SUMMARY.md | 249 +++++++---------------------- backend/internal/api/api.go | 26 +++ backend/internal/server/server.go | 1 + backend/tests/file_listing_test.go | 141 ++++++++++++++++ 4 files changed, 224 insertions(+), 193 deletions(-) create mode 100644 backend/tests/file_listing_test.go diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 8a86d44..95e5b55 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -1,210 +1,73 @@ -# Implementation Summary +# Implementation Summary: File Listing Feature -## Overview +## Problem +The frontend was unable to list existing files because the backend API did not have an endpoint to retrieve the list of files in the data directory. -Successfully implemented a WYSIWYG Markdown Editor with Go backend and React/TypeScript frontend according to SPEC.md. +## Root Cause +The backend API handler only supported individual file operations (`GET /api/{filename}.md`, `POST /api/{filename}.md`, etc.) but had no endpoint to list all files in the data directory. The frontend was attempting to fetch `/api` to get the file list, but this route was not configured. -## Backend Implementation (Go) +## Solution +Added a new endpoint `GET /api` that returns a JSON list of all markdown files in the data directory. -### Completed Milestones +### Changes Made -**B1: CLI & Server Setup** ✅ -- Cobra CLI with `--data-dir`, `--port`, `--host` flags -- HTTP server with basic routing -- Default values: data-dir=./data, port=8080, host=127.0.0.1 +#### 1. Backend API (`backend/internal/api/api.go`) +- Added `handleListFiles()` method that: + - Reads all files from the data directory + - Filters to only include `.md` files + - Returns JSON response with format: `{"files": ["file1.md", "file2.md", ...]}` +- Modified `handleGet()` to check if filename is empty and call `handleListFiles()` if so -**B2: CRUD API** ✅ -- REST endpoints for markdown files: - - GET /api/{filename}.md - Read file - - POST /api/{filename}.md - Create file - - PUT /api/{filename}.md - Update file - - DELETE /api/{filename}.md - Delete file -- JSON error responses (4xx/5xx) +#### 2. Backend Server (`backend/internal/server/server.go`) +- Added route handler for `/api` with GET method +- Kept existing route handler for `/api/{filename:.+.md}` -**B3: File Storage** ✅ -- Read/write .md files to disk -- Flat file structure in data directory +### API Endpoints -**B4: Logging** ✅ -- Comprehensive logrus logging for all operations -- Info level logging with timestamps +#### New Endpoint +- **Method:** GET +- **Path:** `/api` +- **Response:** `{"files": ["file1.md", "file2.md", ...]}` +- **Status Codes:** + - 200 OK - Success + - 500 Internal Server Error - Failed to read directory -**B5: Static Assets** ✅ -- Serves frontend build files from frontend/dist -- Proper routing to serve index.html and assets +#### Existing Endpoints (Unchanged) +- **GET** `/api/{filename}.md` - Get file content +- **POST** `/api/{filename}.md` - Create file +- **PUT** `/api/{filename}.md` - Update file +- **DELETE** `/api/{filename}.md` - Delete file -**B6: Backend Tests** ✅ -- CRUD round-trip tests (create, read, update, delete) -- Static asset serving tests -- All tests passing +### Testing +Added comprehensive tests in `backend/tests/file_listing_test.go`: +- `TestFileListing` - Verifies multiple markdown files are listed +- `TestFileListingWithNonMarkdownFiles` - Verifies only `.md` files are returned +- `TestFileListingEmptyDirectory` - Verifies empty array for empty directory -### Backend Structure +All existing tests continue to pass. -``` -backend/ - cmd/backend/ - main.go - Entry point with Cobra CLI - internal/ - api/ - api.go - API handler with CRUD operations - logger/ - logger.go - Logrus logger setup - server/ - server.go - HTTP server with routing - tests/ - api_test.go - Comprehensive tests - go.mod - go.sum - Makefile +### Frontend Compatibility +The frontend (`frontend/src/App.tsx`) already expects the correct response format: +```typescript +const loadFiles = async () => { + const response = await fetch('/api') + if (response.ok) { + const data = await response.json() + setFiles(data.files || []) // Expects { files: string[] } + } +} ``` -## Frontend Implementation (React + TypeScript + Tailwind) - -### Completed Milestones - -**F1: Project Setup** ✅ -- React + TypeScript + Tailwind configured -- Vite as build tool -- ESLint and Prettier configured - -**F2: File Management UI** ✅ -- List markdown files -- Create new documents -- Open, save, delete files -- API integration with backend - -**F3: Editor & Preview** ✅ -- Markdown editor with live GFM preview -- React Markdown with remark-gfm plugin -- Side-by-side editor/preview layout - -**F4: Theme System** ✅ -- Dark, Light, and System themes -- Theme switcher dropdown -- Theme persistence via localStorage -- Dark mode CSS classes - -**F5: Responsive Design** ✅ -- Works at 320px (mobile) and 1920px (desktop) -- Tailwind responsive utilities -- Flexbox layout that adapts to screen size - -**F6: Frontend Tests** ✅ -- Core functionality tests -- Theme switching tests -- File management tests -- All tests passing - -### Frontend Structure - -``` -frontend/ - src/ - App.tsx - Main application component - main.tsx - React entry point - index.css - Global styles - setupTests.ts - Test setup - App.test.tsx - Component tests - package.json - vite.config.ts - tailwind.config.js - postcss.config.js - tsconfig.json - index.html -``` - -## Integration - -**I1: End-to-end** ✅ -- Full CRUD workflow tested -- Frontend to backend communication verified -- Static asset serving confirmed - -## Testing - -### Backend Tests -```bash -cd backend -make test -``` -- Tests CRUD operations -- Tests static asset serving -- All tests passing - -### Frontend Tests -```bash -cd frontend -npm test -``` -- Tests component rendering -- Tests theme switching -- Tests file management -- All tests passing - -## Build Process - -### Backend Build -```bash -cd backend -make build -``` -- Output: `bin/markdown-editor` - -### Frontend Build -```bash -cd frontend -npm run build -``` -- Output: `dist/` directory with optimized assets - -## Running the Application - -```bash -# Start the server -./backend/bin/markdown-editor - -# Or with custom configuration -./backend/bin/markdown-editor --data-dir ./my-data --port 3000 --host 0.0.0.0 - -# Access at http://localhost:8080 -``` - -## API Endpoints - -- `GET /api/{filename}.md` - Get markdown file content -- `POST /api/{filename}.md` - Create a new markdown file -- `PUT /api/{filename}.md` - Update an existing markdown file -- `DELETE /api/{filename}.md` - Delete a markdown file - -## Features Implemented - -✅ CLI with Cobra (--data-dir, --port, --host) -✅ REST API for markdown files (CRUD) -✅ File storage on disk -✅ Logrus logging -✅ Static asset serving -✅ React + TypeScript + Tailwind frontend -✅ Markdown editor with live preview -✅ File management (list, create, open, save, delete) -✅ Theme system (Dark, Light, System) -✅ Responsive design (mobile to desktop) -✅ Comprehensive tests (backend and frontend) -✅ End-to-end integration - -## Technical Stack - -- **Backend**: Go 1.21, Cobra, Gorilla Mux, Logrus -- **Frontend**: React 18, TypeScript, Tailwind CSS, Vite -- **Markdown**: React Markdown, remark-gfm -- **Testing**: Vitest, Testing Library, Go test -- **Build**: Makefile, npm scripts +No frontend changes were required. ## Verification +The implementation was verified by: +1. Running all existing tests - ✅ PASS +2. Running new file listing tests - ✅ PASS +3. Manual API testing with curl - ✅ WORKING +4. Frontend build verification - ✅ SUCCESS -All requirements from SPEC.md have been met: -- ✅ CLI starts with defaults -- ✅ CRUD works end-to-end -- ✅ Static assets are properly served -- ✅ Theme switch & persistence -- ✅ Responsive at 320px and 1920px -- ✅ All tests passing +## Files Modified +- `backend/internal/api/api.go` - Added file listing functionality +- `backend/internal/server/server.go` - Added `/api` route +- `backend/tests/file_listing_test.go` - Added comprehensive tests (NEW FILE) diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index ac17a0b..bee1707 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -38,10 +38,36 @@ func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (h *APIHandler) handleListFiles(w http.ResponseWriter, r *http.Request) { + h.log.Info("GET request for file listing") + + files, err := os.ReadDir(h.dataDir) + if err != nil { + h.log.Errorf("Error reading data directory: %v", err) + h.writeError(w, http.StatusInternalServerError, "failed to list files") + return + } + + filenames := []string{} + for _, file := range files { + if !file.IsDir() && filepath.Ext(file.Name()) == ".md" { + filenames = append(filenames, file.Name()) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string][]string{"files": filenames}) +} + func (h *APIHandler) handleGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) filename := vars["filename"] + if filename == "" { + h.handleListFiles(w, r) + return + } + h.log.Infof("GET request for file: %s", filename) filepath := filepath.Join(h.dataDir, filename) diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 692fbba..5ab400f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -31,6 +31,7 @@ func NewServer(host string, port int, handler http.Handler, log *logrus.Logger) func (s *Server) Start() error { router := mux.NewRouter() + router.Handle("/api", s.handler).Methods("GET") router.Handle("/api/{filename:.+.md}", s.handler) router.PathPrefix("/").Handler(http.FileServer(http.Dir("frontend/dist"))) diff --git a/backend/tests/file_listing_test.go b/backend/tests/file_listing_test.go new file mode 100644 index 0000000..72a75e7 --- /dev/null +++ b/backend/tests/file_listing_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/evanreichard/markdown-editor/internal/api" + "github.com/evanreichard/markdown-editor/internal/logger" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +func TestFileListing(t *testing.T) { + dataDir, err := setupTestDir() + if err != nil { + t.Fatalf("Failed to create test dir: %v", err) + } + defer cleanupTestDir(dataDir) + + // Create some test files + testFiles := []string{ + "file1.md", + "file2.md", + "file3.md", + } + + for _, filename := range testFiles { + filepath := filepath.Join(dataDir, filename) + content := "# " + filename + "\n\nContent of " + filename + if err := os.WriteFile(filepath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + log := logger.NewLogger() + handler := api.NewAPIHandler(dataDir, log) + router := mux.NewRouter() + router.Handle("/api", handler).Methods("GET") + router.Handle("/api/{filename:.+\\.md}", handler) + + // Test GET /api (list files) + req := httptest.NewRequest("GET", "/api", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + var response struct { + Files []string `json:"files"` + } + body, _ := io.ReadAll(w.Body) + assert.NoError(t, json.Unmarshal(body, &response)) + + // Verify all test files are returned + assert.Len(t, response.Files, 3) + assert.Contains(t, response.Files, "file1.md") + assert.Contains(t, response.Files, "file2.md") + assert.Contains(t, response.Files, "file3.md") +} + +func TestFileListingWithNonMarkdownFiles(t *testing.T) { + dataDir, err := setupTestDir() + if err != nil { + t.Fatalf("Failed to create test dir: %v", err) + } + defer cleanupTestDir(dataDir) + + // Create test files including non-markdown files + testFiles := []string{ + "file1.md", + "file2.txt", + "file3.md", + "file4.log", + } + + for _, filename := range testFiles { + filepath := filepath.Join(dataDir, filename) + content := "Content of " + filename + if err := os.WriteFile(filepath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + log := logger.NewLogger() + handler := api.NewAPIHandler(dataDir, log) + router := mux.NewRouter() + router.Handle("/api", handler).Methods("GET") + router.Handle("/api/{filename:.+\\.md}", handler) + + // Test GET /api (list files) + req := httptest.NewRequest("GET", "/api", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + var response struct { + Files []string `json:"files"` + } + body, _ := io.ReadAll(w.Body) + assert.NoError(t, json.Unmarshal(body, &response)) + + // Verify only markdown files are returned + assert.Len(t, response.Files, 2) + assert.Contains(t, response.Files, "file1.md") + assert.Contains(t, response.Files, "file3.md") + assert.NotContains(t, response.Files, "file2.txt") + assert.NotContains(t, response.Files, "file4.log") +} + +func TestFileListingEmptyDirectory(t *testing.T) { + dataDir, err := setupTestDir() + if err != nil { + t.Fatalf("Failed to create test dir: %v", err) + } + defer cleanupTestDir(dataDir) + + log := logger.NewLogger() + handler := api.NewAPIHandler(dataDir, log) + router := mux.NewRouter() + router.Handle("/api", handler).Methods("GET") + router.Handle("/api/{filename:.+\\.md}", handler) + + // Test GET /api (list files in empty directory) + req := httptest.NewRequest("GET", "/api", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + var response struct { + Files []string `json:"files"` + } + body, _ := io.ReadAll(w.Body) + assert.NoError(t, json.Unmarshal(body, &response)) + + // Verify empty array is returned + assert.Len(t, response.Files, 0) +}