Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/api/dto/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package dto

// App is the response shape for a single app.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}

// AppList is the response shape for list apps.
type AppList struct {
NextCursor string `json:"next_cursor"`
Items []App `json:"items"`
}

// Server is the response shape for a server within an app listing.
type Server struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
NumCPU int `json:"num_cpu"`
TimestampUTC string `json:"timestamp_utc"`
}

// ServerList is the response shape for listing servers in an app.
type ServerList struct {
Total int64 `json:"total"`
NextCursor string `json:"next_cursor"`
Items []Server `json:"items"`
}

// Status is a generic OK/ERR style response.
type Status struct {
Status string `json:"status"`
}

// StatusCount is used where we also want to return a count.
type StatusCount struct {
Status string `json:"status"`
Count int `json:"count"`
}
278 changes: 278 additions & 0 deletions internal/api/handlers/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package handlers

import (
"encoding/json"
"net/http"
"strconv"
"strings"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"

"replicator/internal/api/dto"
mw "replicator/internal/api/middleware"
"replicator/internal/models"
"replicator/internal/storage"
)

type createAppReq struct {
Name string `json:"name"`
Description string `json:"description"`
}

type addServersReq struct {
ServerIDs []string `json:"metadata_ids"`
}

// POST /api/apps
func CreateAppHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
log.Error("CreateAppHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

var req createAppReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error("CreateAppHandler: decode failed", "error", err.Error())
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

name := strings.TrimSpace(req.Name)
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}

var cnt int64
if err := store.DB.Model(&models.App{}).Where("name = ?", name).Count(&cnt).Error; err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "name already exists", http.StatusConflict)
return
}

app, err := store.CreateApp(storage.AppCreate{
ID: uuid.NewString(),
Name: name,
Description: req.Description,
})
if err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}

resp := dto.App{
ID: app.ID,
Name: app.Name,
Description: app.Description,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// DELETE /api/apps/{id}
func DeleteAppHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil || store.DB == nil {
log.Error("DeleteAppHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

id := chi.URLParam(r, "id")
if strings.TrimSpace(id) == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}

if err := store.DeleteApp(storage.AppSelector{ID: &id}); err != nil {
log.Error("DeleteAppHandler: db error", "error", err.Error())
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dto.Status{Status: "ok"})
}

// GET /api/apps
func ListAppsHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
log.Error("ListAppsHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

afterID := r.URL.Query().Get("after_id")
limit := 50
if lq := r.URL.Query().Get("limit"); lq != "" {
if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 {
limit = v
}
}

items, next, err := store.ListApps(afterID, limit)
if err != nil {
http.Error(w, "list failed", http.StatusInternalServerError)
return
}

out := dto.AppList{
NextCursor: next,
Items: make([]dto.App, 0, len(items)),
}
for i := range items {
out.Items = append(out.Items, dto.App{
ID: items[i].ID,
Name: items[i].Name,
Description: items[i].Description,
})
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}

// GET /api/apps/{id}
func GetAppByIDHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
log.Error("GetAppByIDHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

id := chi.URLParam(r, "id")
app, err := store.FindApp(storage.AppSelector{ID: &id})
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}

resp := dto.App{
ID: app.ID,
Name: app.Name,
Description: app.Description,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// POST /api/apps/{appID}/servers
func AddServersToAppHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
log.Error("AddServersToAppHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

appID := chi.URLParam(r, "appID")

var req addServersReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error("AddServersToAppHandler: decode failed", "error", err.Error())
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.ServerIDs) == 0 {
http.Error(w, "metadata_ids required", http.StatusBadRequest)
return
}

if err := store.ModifyAppServers(
storage.AppSelector{ID: &appID},
req.ServerIDs,
storage.MembershipAdd); err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}

resp := dto.StatusCount{Status: "ok", Count: len(req.ServerIDs)}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// DELETE /api/apps/{appID}/servers/{serverID}
func RemoveServerFromAppHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
log.Error("RemoveServerFromAppHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

appID := chi.URLParam(r, "appID")
serverID := chi.URLParam(r, "serverID")

if err := store.ModifyAppServers(
storage.AppSelector{ID: &appID},
[]string{serverID},
storage.MembershipRemove); err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dto.Status{Status: "ok"})
}

// GET /api/apps/{appID}/servers
func ListServersForAppHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
log.Error("ListServersForAppHandler: store missing")
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

appID := chi.URLParam(r, "appID")
afterID := r.URL.Query().Get("after_id")
limit := 50
if lq := r.URL.Query().Get("limit"); lq != "" {
if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 {
limit = v
}
}

servers, total, next, err := store.ListAppServers(
storage.AppSelector{ID: &appID},
storage.Cursor{AfterID: afterID, Limit: limit},
)
if err != nil {
http.Error(w, "list failed", http.StatusInternalServerError)
return
}

out := dto.ServerList{
Total: total,
NextCursor: next,
Items: make([]dto.Server, 0, len(servers)),
}
for i := range servers {
out.Items = append(out.Items, dto.Server{
ID: servers[i].ID,
Hostname: servers[i].Hostname,
OS: servers[i].OS,
Arch: servers[i].Arch,
NumCPU: servers[i].NumCPU,
TimestampUTC: servers[i].TimestampUTC,
})
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
28 changes: 28 additions & 0 deletions internal/api/handlers/debug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package handlers

import (
"encoding/json"
"net/http"

mw "replicator/internal/api/middleware"
)

func SeedHandler(w http.ResponseWriter, r *http.Request) {
log := mw.GetLogFromCtx(r)
store := mw.StoreFrom(r)
if store == nil {
http.Error(w, "store missing", http.StatusInternalServerError)
return
}

if err := store.SeedSampleData(r.Context()); err != nil {
log.Error("SeedHandler failed", "error", err.Error())
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"msg": "Sample data inserted",
})
}
14 changes: 14 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ func NewRouter(store *storage.Store, logger *slog.Logger) http.Handler {
r.Post("/discover", handlers.DiscoverHandler)
r.Get("/servers", handlers.ListServersHandler)
r.Get("/servers/{id}", handlers.GetServerHandler)

r.Route("/apps", func(r chi.Router) {
r.Post("/", handlers.CreateAppHandler)
r.Get("/", handlers.ListAppsHandler)
r.Get("/{id}", handlers.GetAppByIDHandler)
r.Post("/{appID}/servers", handlers.AddServersToAppHandler)
r.Delete("/{appID}/servers/{serverID}", handlers.RemoveServerFromAppHandler)
r.Get("/{appID}/servers", handlers.ListServersForAppHandler)
r.Delete("/{id}", handlers.DeleteAppHandler)
})

// debug seed route — IMPORTANT: stays inside this block
r.Post("/debug/seed", handlers.SeedHandler)

})

// UI routes
Expand Down
24 changes: 24 additions & 0 deletions internal/models/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package models

import "time"

// --- apps ---
type App struct {
ID string `json:"id" gorm:"primaryKey;size:64;not null"`
Name string `json:"name" gorm:"size:255;not null;uniqueIndex"`
Description string `json:"description" gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time

Servers []Metadata `json:"servers" gorm:"many2many:app_servers"`
}

type AppServer struct {
AppID string `json:"app_id" gorm:"size:64;not null;primaryKey;column:app_id"`
MetadataID string `json:"metadata_id" gorm:"size:64;not null;primaryKey;column:metadata_id"`
CreatedAt time.Time

// Optional FKs (good for cascades)
App App `gorm:"foreignKey:AppID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Metadata Metadata `gorm:"foreignKey:MetadataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
Loading