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:
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
|
||||||
|
|||||||
141
backend/tests/file_listing_test.go
Normal file
141
backend/tests/file_listing_test.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user