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
This commit is contained in:
2026-02-06 21:23:59 -05:00
parent 2a9e793971
commit a074f5a854
4 changed files with 224 additions and 193 deletions

View File

@@ -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** #### 1. Backend API (`backend/internal/api/api.go`)
- Cobra CLI with `--data-dir`, `--port`, `--host` flags - Added `handleListFiles()` method that:
- HTTP server with basic routing - Reads all files from the data directory
- Default values: data-dir=./data, port=8080, host=127.0.0.1 - 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** #### 2. Backend Server (`backend/internal/server/server.go`)
- REST endpoints for markdown files: - Added route handler for `/api` with GET method
- GET /api/{filename}.md - Read file - Kept existing route handler for `/api/{filename:.+.md}`
- POST /api/{filename}.md - Create file
- PUT /api/{filename}.md - Update file
- DELETE /api/{filename}.md - Delete file
- JSON error responses (4xx/5xx)
**B3: File Storage** ### API Endpoints
- Read/write .md files to disk
- Flat file structure in data directory
**B4: Logging** #### New Endpoint
- Comprehensive logrus logging for all operations - **Method:** GET
- Info level logging with timestamps - **Path:** `/api`
- **Response:** `{"files": ["file1.md", "file2.md", ...]}`
- **Status Codes:**
- 200 OK - Success
- 500 Internal Server Error - Failed to read directory
**B5: Static Assets** #### Existing Endpoints (Unchanged)
- Serves frontend build files from frontend/dist - **GET** `/api/{filename}.md` - Get file content
- Proper routing to serve index.html and assets - **POST** `/api/{filename}.md` - Create file
- **PUT** `/api/{filename}.md` - Update file
- **DELETE** `/api/{filename}.md` - Delete file
**B6: Backend Tests** ### Testing
- CRUD round-trip tests (create, read, update, delete) Added comprehensive tests in `backend/tests/file_listing_test.go`:
- Static asset serving tests - `TestFileListing` - Verifies multiple markdown files are listed
- All tests passing - `TestFileListingWithNonMarkdownFiles` - Verifies only `.md` files are returned
- `TestFileListingEmptyDirectory` - Verifies empty array for empty directory
### Backend Structure All existing tests continue to pass.
``` ### Frontend Compatibility
backend/ The frontend (`frontend/src/App.tsx`) already expects the correct response format:
cmd/backend/ ```typescript
main.go - Entry point with Cobra CLI const loadFiles = async () => {
internal/ const response = await fetch('/api')
api/ if (response.ok) {
api.go - API handler with CRUD operations const data = await response.json()
logger/ setFiles(data.files || []) // Expects { files: string[] }
logger.go - Logrus logger setup }
server/ }
server.go - HTTP server with routing
tests/
api_test.go - Comprehensive tests
go.mod
go.sum
Makefile
``` ```
## Frontend Implementation (React + TypeScript + Tailwind) No frontend changes were required.
### 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
## Verification ## 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: ## Files Modified
- ✅ CLI starts with defaults - `backend/internal/api/api.go` - Added file listing functionality
- ✅ CRUD works end-to-end - `backend/internal/server/server.go` - Added `/api` route
- ✅ Static assets are properly served - `backend/tests/file_listing_test.go` - Added comprehensive tests (NEW FILE)
- ✅ Theme switch & persistence
- ✅ Responsive at 320px and 1920px
- ✅ All tests passing

View File

@@ -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) { func (h *APIHandler) handleGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
filename := vars["filename"] filename := vars["filename"]
if filename == "" {
h.handleListFiles(w, r)
return
}
h.log.Infof("GET request for file: %s", filename) h.log.Infof("GET request for file: %s", filename)
filepath := filepath.Join(h.dataDir, filename) filepath := filepath.Join(h.dataDir, filename)

View File

@@ -31,6 +31,7 @@ func NewServer(host string, port int, handler http.Handler, log *logrus.Logger)
func (s *Server) Start() error { func (s *Server) Start() error {
router := mux.NewRouter() router := mux.NewRouter()
router.Handle("/api", s.handler).Methods("GET")
router.Handle("/api/{filename:.+.md}", s.handler) router.Handle("/api/{filename:.+.md}", s.handler)
router.PathPrefix("/").Handler(http.FileServer(http.Dir("frontend/dist"))) router.PathPrefix("/").Handler(http.FileServer(http.Dir("frontend/dist")))

View File

@@ -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)
}