Premier commit déjà bien avancé
This commit is contained in:
436
internal/api/rest_handler.go
Normal file
436
internal/api/rest_handler.go
Normal file
@ -0,0 +1,436 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/mathieu/project-notes/internal/indexer"
|
||||
)
|
||||
|
||||
// REST API Structures
|
||||
// ===================
|
||||
|
||||
// NoteResponse représente une note dans les réponses API
|
||||
type NoteResponse struct {
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"` // Full content with front matter
|
||||
Body string `json:"body,omitempty"` // Body without front matter
|
||||
FrontMatter *indexer.FullFrontMatter `json:"frontMatter,omitempty"`
|
||||
LastModified string `json:"lastModified"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// NoteMetadata représente les métadonnées d'une note (pour la liste)
|
||||
type NoteMetadata struct {
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
LastModified string `json:"lastModified"`
|
||||
Date string `json:"date"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ListNotesResponse représente la réponse de liste de notes
|
||||
type ListNotesResponse struct {
|
||||
Notes []NoteMetadata `json:"notes"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ErrorResponse représente une erreur API
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
// NoteRequest représente une requête de création/modification de note
|
||||
type NoteRequest struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
FrontMatter *indexer.FullFrontMatter `json:"frontMatter,omitempty"`
|
||||
}
|
||||
|
||||
// REST API Handlers
|
||||
// =================
|
||||
|
||||
// handleRESTNotes route les requêtes REST vers les bons handlers
|
||||
func (h *Handler) handleRESTNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract path after /api/v1/notes/
|
||||
const prefix = "/api/v1/notes"
|
||||
path := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
|
||||
// Si pas de path spécifique, c'est une liste
|
||||
if path == "" {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleRESTListNotes(w, r)
|
||||
default:
|
||||
h.sendJSONError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validation du path
|
||||
cleanPath := filepath.Clean(path)
|
||||
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
|
||||
h.sendJSONError(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if filepath.Ext(cleanPath) != ".md" {
|
||||
h.sendJSONError(w, "Only .md files are supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Router selon la méthode HTTP
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleRESTGetNote(w, r, cleanPath)
|
||||
case http.MethodPut:
|
||||
h.handleRESTPutNote(w, r, cleanPath)
|
||||
case http.MethodDelete:
|
||||
h.handleRESTDeleteNote(w, r, cleanPath)
|
||||
default:
|
||||
h.sendJSONError(w, "Method not allowed. Supported: GET, PUT, DELETE", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRESTListNotes liste toutes les notes avec leurs métadonnées
|
||||
// GET /api/v1/notes
|
||||
func (h *Handler) handleRESTListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
notes := []NoteMetadata{}
|
||||
|
||||
err := filepath.WalkDir(h.notesDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ignorer les dossiers et fichiers cachés
|
||||
if d.IsDir() || strings.HasPrefix(d.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ne garder que les .md
|
||||
if filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(h.notesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Lire les métadonnées
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
h.logger.Printf("Erreur lecture info fichier %s: %v", relPath, err)
|
||||
return nil // Continue malgré l'erreur
|
||||
}
|
||||
|
||||
// Extraire le front matter
|
||||
fm, _, err := indexer.ExtractFrontMatterAndBody(path)
|
||||
if err != nil {
|
||||
h.logger.Printf("Erreur extraction front matter %s: %v", relPath, err)
|
||||
// Continuer avec des valeurs par défaut
|
||||
fm = indexer.FullFrontMatter{
|
||||
Title: strings.TrimSuffix(d.Name(), ".md"),
|
||||
Tags: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
notes = append(notes, NoteMetadata{
|
||||
Path: filepath.ToSlash(relPath),
|
||||
Title: fm.Title,
|
||||
Tags: fm.Tags,
|
||||
LastModified: fm.LastModified,
|
||||
Date: fm.Date,
|
||||
Size: info.Size(),
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
h.logger.Printf("Erreur lors du listing des notes: %v", err)
|
||||
h.sendJSONError(w, "Failed to list notes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := ListNotesResponse{
|
||||
Notes: notes,
|
||||
Total: len(notes),
|
||||
}
|
||||
|
||||
h.sendJSON(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRESTGetNote retourne une note spécifique
|
||||
// GET /api/v1/notes/{path}
|
||||
// Supporte Accept: application/json ou text/markdown
|
||||
func (h *Handler) handleRESTGetNote(w http.ResponseWriter, r *http.Request, notePath string) {
|
||||
fullPath := filepath.Join(h.notesDir, notePath)
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
h.sendJSONError(w, "Note not found", http.StatusNotFound)
|
||||
} else {
|
||||
h.logger.Printf("Erreur stat fichier %s: %v", notePath, err)
|
||||
h.sendJSONError(w, "Failed to access note", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Lire le contenu
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
h.logger.Printf("Erreur lecture fichier %s: %v", notePath, err)
|
||||
h.sendJSONError(w, "Failed to read note", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extraire front matter et body
|
||||
fm, body, err := indexer.ExtractFrontMatterAndBody(fullPath)
|
||||
if err != nil {
|
||||
h.logger.Printf("Erreur extraction front matter %s: %v", notePath, err)
|
||||
// Continuer sans front matter
|
||||
fm = indexer.FullFrontMatter{}
|
||||
body = string(content)
|
||||
}
|
||||
|
||||
// Content negotiation
|
||||
accept := r.Header.Get("Accept")
|
||||
|
||||
// Si client veut du Markdown brut
|
||||
if strings.Contains(accept, "text/markdown") || strings.Contains(accept, "text/plain") {
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(notePath)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
return
|
||||
}
|
||||
|
||||
// Par défaut, retourner du JSON
|
||||
response := NoteResponse{
|
||||
Path: filepath.ToSlash(notePath),
|
||||
Title: fm.Title,
|
||||
Content: string(content),
|
||||
Body: body,
|
||||
FrontMatter: &fm,
|
||||
LastModified: fm.LastModified,
|
||||
Size: info.Size(),
|
||||
}
|
||||
|
||||
h.sendJSON(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleRESTPutNote crée ou met à jour une note
|
||||
// PUT /api/v1/notes/{path}
|
||||
// Accepte: application/json, text/markdown, ou multipart/form-data
|
||||
func (h *Handler) handleRESTPutNote(w http.ResponseWriter, r *http.Request, notePath string) {
|
||||
fullPath := filepath.Join(h.notesDir, notePath)
|
||||
isNewFile := false
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
isNewFile = true
|
||||
}
|
||||
|
||||
var finalContent string
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
|
||||
// Parser selon le Content-Type
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
// JSON Request
|
||||
var req NoteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.sendJSONError(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Si content fourni directement, l'utiliser
|
||||
if req.Content != "" {
|
||||
finalContent = req.Content
|
||||
} else {
|
||||
// Sinon, construire depuis body + frontMatter
|
||||
var fm indexer.FullFrontMatter
|
||||
if req.FrontMatter != nil {
|
||||
fm = *req.FrontMatter
|
||||
} else {
|
||||
fm = indexer.FullFrontMatter{}
|
||||
}
|
||||
|
||||
// Remplir les champs manquants
|
||||
now := time.Now()
|
||||
if fm.Title == "" {
|
||||
fm.Title = strings.TrimSuffix(filepath.Base(notePath), ".md")
|
||||
fm.Title = strings.ReplaceAll(fm.Title, "-", " ")
|
||||
fm.Title = strings.Title(fm.Title)
|
||||
}
|
||||
if isNewFile || fm.Date == "" {
|
||||
fm.Date = now.Format("02-01-2006")
|
||||
}
|
||||
fm.LastModified = now.Format("02-01-2006:15:04")
|
||||
if fm.Tags == nil {
|
||||
fm.Tags = []string{}
|
||||
}
|
||||
|
||||
// Marshal front matter
|
||||
fmBytes, err := yaml.Marshal(fm)
|
||||
if err != nil {
|
||||
h.logger.Printf("Erreur marshalling front matter: %v", err)
|
||||
h.sendJSONError(w, "Failed to generate front matter", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
body := req.Body
|
||||
if body == "" {
|
||||
body = "\n# " + fm.Title + "\n\nVotre contenu ici..."
|
||||
}
|
||||
|
||||
finalContent = "---\n" + string(fmBytes) + "---\n" + body
|
||||
}
|
||||
} else if strings.Contains(contentType, "text/markdown") || strings.Contains(contentType, "text/plain") {
|
||||
// Raw Markdown
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.sendJSONError(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
finalContent = string(bodyBytes)
|
||||
|
||||
// Si pas de front matter, en générer un
|
||||
if !strings.HasPrefix(finalContent, "---") {
|
||||
now := time.Now()
|
||||
fm := indexer.FullFrontMatter{
|
||||
Title: strings.TrimSuffix(filepath.Base(notePath), ".md"),
|
||||
Date: now.Format("02-01-2006"),
|
||||
LastModified: now.Format("02-01-2006:15:04"),
|
||||
Tags: []string{},
|
||||
}
|
||||
fmBytes, _ := yaml.Marshal(fm)
|
||||
finalContent = "---\n" + string(fmBytes) + "---\n" + finalContent
|
||||
} else {
|
||||
// Mettre à jour le LastModified du front matter existant
|
||||
fm, body, err := indexer.ExtractFrontMatterAndBodyFromReader(strings.NewReader(finalContent))
|
||||
if err == nil {
|
||||
fm.LastModified = time.Now().Format("02-01-2006:15:04")
|
||||
fmBytes, _ := yaml.Marshal(fm)
|
||||
finalContent = "---\n" + string(fmBytes) + "---\n" + body
|
||||
}
|
||||
}
|
||||
} else {
|
||||
h.sendJSONError(w, "Unsupported Content-Type. Use application/json or text/markdown", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
|
||||
// Créer les dossiers parents si nécessaire
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
||||
h.logger.Printf("Erreur création dossier pour %s: %v", notePath, err)
|
||||
h.sendJSONError(w, "Failed to create parent directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Écrire le fichier
|
||||
if err := os.WriteFile(fullPath, []byte(finalContent), 0o644); err != nil {
|
||||
h.logger.Printf("Erreur écriture fichier %s: %v", notePath, err)
|
||||
h.sendJSONError(w, "Failed to write note", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Ré-indexer en arrière-plan
|
||||
go func() {
|
||||
if err := h.idx.Load(h.notesDir); err != nil {
|
||||
h.logger.Printf("Échec ré-indexation après PUT: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Lire les métadonnées pour la réponse
|
||||
info, _ := os.Stat(fullPath)
|
||||
fm, body, _ := indexer.ExtractFrontMatterAndBody(fullPath)
|
||||
|
||||
response := NoteResponse{
|
||||
Path: filepath.ToSlash(notePath),
|
||||
Title: fm.Title,
|
||||
Content: finalContent,
|
||||
Body: body,
|
||||
FrontMatter: &fm,
|
||||
LastModified: fm.LastModified,
|
||||
Size: info.Size(),
|
||||
}
|
||||
|
||||
statusCode := http.StatusOK
|
||||
if isNewFile {
|
||||
statusCode = http.StatusCreated
|
||||
}
|
||||
|
||||
h.sendJSON(w, response, statusCode)
|
||||
}
|
||||
|
||||
// handleRESTDeleteNote supprime une note
|
||||
// DELETE /api/v1/notes/{path}
|
||||
func (h *Handler) handleRESTDeleteNote(w http.ResponseWriter, r *http.Request, notePath string) {
|
||||
fullPath := filepath.Join(h.notesDir, notePath)
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
h.sendJSONError(w, "Note not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Supprimer le fichier
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
h.logger.Printf("Erreur suppression fichier %s: %v", notePath, err)
|
||||
h.sendJSONError(w, "Failed to delete note", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Ré-indexer en arrière-plan
|
||||
go func() {
|
||||
if err := h.idx.Load(h.notesDir); err != nil {
|
||||
h.logger.Printf("Échec ré-indexation après DELETE: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Réponse de succès
|
||||
response := map[string]interface{}{
|
||||
"message": "Note deleted successfully",
|
||||
"path": filepath.ToSlash(notePath),
|
||||
}
|
||||
|
||||
h.sendJSON(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
// =================
|
||||
|
||||
// sendJSON envoie une réponse JSON
|
||||
func (h *Handler) sendJSON(w http.ResponseWriter, data interface{}, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
h.logger.Printf("Erreur encodage JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendJSONError envoie une erreur JSON
|
||||
func (h *Handler) sendJSONError(w http.ResponseWriter, message string, statusCode int) {
|
||||
response := ErrorResponse{
|
||||
Error: http.StatusText(statusCode),
|
||||
Message: message,
|
||||
Code: statusCode,
|
||||
}
|
||||
h.sendJSON(w, response, statusCode)
|
||||
}
|
||||
Reference in New Issue
Block a user