diff --git a/Dockerfile b/Dockerfile index 788051a..633cb37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . +RUN go run github.com/swaggo/swag/cmd/swag@latest init -g cmd/api/main.go +RUN go mod tidy RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o apocapoc-api cmd/api/main.go FROM alpine:latest diff --git a/README.md b/README.md index 7ddf670..9ef0a1e 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ go run cmd/api/main.go ## API Documentation -Once running, visit `http://localhost:8080/api/v1/docs` for interactive API documentation. +Once running, visit `http://localhost:8080/api/v1/docs` for interactive Swagger documentation. ## Architecture diff --git a/cmd/api/main.go b/cmd/api/main.go index 95b81cb..b80e872 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,6 +15,25 @@ import ( "apocapoc-api/internal/infrastructure/persistence/sqlite" ) +// @title Apocapoc API +// @version 1.0 +// @description Self-hosted habit tracking service +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url https://github.com/davidfolch/apocapoc-api + +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT + +// @host localhost:8080 +// @BasePath /api/v1 + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token. + func main() { cfg, err := config.Load() if err != nil { diff --git a/go.mod b/go.mod index fcf6f52..32b724b 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.24.0 toolchain go1.24.10 require ( - github.com/go-chi/chi/v5 v5.2.3 // indirect - github.com/go-chi/cors v1.2.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect - golang.org/x/crypto v0.45.0 // indirect + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.32 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.16.4 + golang.org/x/crypto v0.45.0 ) diff --git a/internal/infrastructure/http/auth_handlers.go b/internal/infrastructure/http/auth_handlers.go index 9b8ffe1..f81f39b 100644 --- a/internal/infrastructure/http/auth_handlers.go +++ b/internal/infrastructure/http/auth_handlers.go @@ -44,6 +44,18 @@ type AuthResponse struct { UserID string `json:"user_id"` } +// Register godoc +// @Summary Register a new user +// @Description Create a new user account +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Registration data" +// @Success 201 {object} AuthResponse +// @Failure 400 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /auth/register [post] func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { var req RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -83,6 +95,18 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { }) } +// Login godoc +// @Summary Login user +// @Description Authenticate user and get JWT token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Login credentials" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /auth/login [post] func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/infrastructure/http/habit_handlers.go b/internal/infrastructure/http/habit_handlers.go index c6a7fe0..1259c0f 100644 --- a/internal/infrastructure/http/habit_handlers.go +++ b/internal/infrastructure/http/habit_handlers.go @@ -49,6 +49,19 @@ func NewHabitHandlers( } } +// CreateHabit godoc +// @Summary Create a new habit +// @Description Create a new habit for the authenticated user +// @Tags habits +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CreateHabitRequest true "Habit data" +// @Success 201 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits [post] func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { var req CreateHabitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -87,6 +100,16 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, map[string]string{"id": habitID}) } +// GetUserHabits godoc +// @Summary Get all user habits +// @Description Get all active habits for the authenticated user +// @Tags habits +// @Produce json +// @Security BearerAuth +// @Success 200 {array} UserHabitResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits [get] func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { @@ -120,6 +143,19 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, response) } +// GetHabitByID godoc +// @Summary Get habit by ID +// @Description Get a specific habit by ID +// @Tags habits +// @Produce json +// @Security BearerAuth +// @Param id path string true "Habit ID" +// @Success 200 {object} UserHabitResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/{id} [get] func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") @@ -161,6 +197,22 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, response) } +// UpdateHabit godoc +// @Summary Update habit +// @Description Update an existing habit +// @Tags habits +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Habit ID" +// @Param request body UpdateHabitRequest true "Update data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/{id} [put] func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") @@ -207,6 +259,19 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } +// ArchiveHabit godoc +// @Summary Archive habit +// @Description Archive (soft delete) a habit +// @Tags habits +// @Produce json +// @Security BearerAuth +// @Param id path string true "Habit ID" +// @Success 200 {object} map[string]string +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/{id} [delete] func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") @@ -237,6 +302,24 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "archived"}) } +// GetHabitEntries godoc +// @Summary Get habit entries +// @Description Get entries (completion history) for a habit with optional date filtering and pagination +// @Tags habits +// @Produce json +// @Security BearerAuth +// @Param id path string true "Habit ID" +// @Param from query string false "Start date (YYYY-MM-DD)" +// @Param to query string false "End date (YYYY-MM-DD)" +// @Param page query int false "Page number" +// @Param limit query int false "Page size (max 100)" +// @Success 200 {object} HabitEntriesResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/{id}/entries [get] func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") @@ -343,6 +426,16 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) respondJSON(w, http.StatusOK, response) } +// GetTodaysHabits godoc +// @Summary Get today's habits +// @Description Get all habits scheduled for today for the authenticated user +// @Tags habits +// @Produce json +// @Security BearerAuth +// @Success 200 {array} TodaysHabitResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/today [get] func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) { userID, ok := GetUserIDFromContext(r.Context()) if !ok { @@ -379,6 +472,21 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) respondJSON(w, http.StatusOK, response) } +// MarkHabit godoc +// @Summary Mark habit as complete +// @Description Mark a habit as completed for a specific date +// @Tags habits +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Habit ID" +// @Param request body MarkHabitRequest true "Mark data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/{id}/mark [post] func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") @@ -416,6 +524,21 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{"status": "marked"}) } +// UnmarkHabit godoc +// @Summary Unmark habit +// @Description Delete a habit entry (unmark completion) +// @Tags habits +// @Produce json +// @Security BearerAuth +// @Param id path string true "Habit ID" +// @Param date path string true "Date (YYYY-MM-DD)" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /habits/{id}/entries/{date} [delete] func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) { habitID := chi.URLParam(r, "id") dateStr := chi.URLParam(r, "date") diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index e7ce331..e7376d4 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -8,6 +8,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + httpSwagger "github.com/swaggo/http-swagger" + + _ "apocapoc-api/docs" ) func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, jwtService *auth.JWTService) *chi.Mux { @@ -22,6 +25,13 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A AllowCredentials: true, })) + r.Get("/api/v1/docs", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/api/v1/docs/index.html", http.StatusMovedPermanently) + }) + r.Get("/api/v1/docs/*", httpSwagger.Handler( + httpSwagger.URL("/api/v1/docs/doc.json"), + )) + r.Get("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`))