Add OpenAPI/Swagger documentation
- Add Swagger dependencies to go.mod - Annotate all API endpoints with Swagger comments - Add Swagger UI at /api/v1/docs endpoint - Auto-generate Swagger docs in Dockerfile build - Update README with API documentation link
This commit is contained in:
@@ -8,6 +8,8 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
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
|
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o apocapoc-api cmd/api/main.go
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ go run cmd/api/main.go
|
|||||||
|
|
||||||
## API Documentation
|
## 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
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ import (
|
|||||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
"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() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ go 1.24.0
|
|||||||
toolchain go1.24.10
|
toolchain go1.24.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2 // indirect
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
github.com/swaggo/http-swagger v1.3.4
|
||||||
|
github.com/swaggo/swag v1.16.4
|
||||||
|
golang.org/x/crypto v0.45.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ type AuthResponse struct {
|
|||||||
UserID string `json:"user_id"`
|
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) {
|
func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
var req RegisterRequest
|
var req RegisterRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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) {
|
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
var req LoginRequest
|
var req LoginRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
var req CreateHabitRequest
|
var req CreateHabitRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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})
|
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) {
|
func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := GetUserIDFromContext(r.Context())
|
userID, ok := GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -120,6 +143,19 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, response)
|
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) {
|
func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) {
|
||||||
habitID := chi.URLParam(r, "id")
|
habitID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -161,6 +197,22 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, response)
|
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) {
|
func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
habitID := chi.URLParam(r, "id")
|
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"})
|
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) {
|
func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
habitID := chi.URLParam(r, "id")
|
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"})
|
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) {
|
func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request) {
|
||||||
habitID := chi.URLParam(r, "id")
|
habitID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -343,6 +426,16 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
|||||||
respondJSON(w, http.StatusOK, response)
|
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) {
|
func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := GetUserIDFromContext(r.Context())
|
userID, ok := GetUserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -379,6 +472,21 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
|||||||
respondJSON(w, http.StatusOK, response)
|
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) {
|
func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
habitID := chi.URLParam(r, "id")
|
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"})
|
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) {
|
func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||||
habitID := chi.URLParam(r, "id")
|
habitID := chi.URLParam(r, "id")
|
||||||
dateStr := chi.URLParam(r, "date")
|
dateStr := chi.URLParam(r, "date")
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"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 {
|
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,
|
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) {
|
r.Get("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
|||||||
Reference in New Issue
Block a user