- Add API token model with SHA-256 hashed tokens
- Add migration 006_add_api_tokens.sql
- Add Bearer token auth middleware
- Add API endpoints under /api/v1/:
- GET /whoami
- POST /tokens (create new API token)
- GET /apps (list all apps)
- POST /apps (create app)
- GET /apps/{id} (get app)
- DELETE /apps/{id} (delete app)
- POST /apps/{id}/deploy (trigger deployment)
- GET /apps/{id}/deployments (list deployments)
- Add comprehensive tests for all API endpoints
- All tests pass, zero lint issues
373 lines
9.9 KiB
Go
373 lines
9.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/middleware"
|
|
"git.eeqj.de/sneak/upaas/internal/models"
|
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
|
)
|
|
|
|
// apiAppResponse is the JSON representation of an app.
|
|
type apiAppResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
RepoURL string `json:"repoUrl"`
|
|
Branch string `json:"branch"`
|
|
DockerfilePath string `json:"dockerfilePath"`
|
|
Status string `json:"status"`
|
|
WebhookSecret string `json:"webhookSecret"`
|
|
SSHPublicKey string `json:"sshPublicKey"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// apiDeploymentResponse is the JSON representation of a deployment.
|
|
type apiDeploymentResponse struct {
|
|
ID int64 `json:"id"`
|
|
AppID string `json:"appId"`
|
|
CommitSHA string `json:"commitSha,omitempty"`
|
|
Status string `json:"status"`
|
|
Duration string `json:"duration,omitempty"`
|
|
StartedAt string `json:"startedAt"`
|
|
FinishedAt string `json:"finishedAt,omitempty"`
|
|
}
|
|
|
|
func appToAPI(a *models.App) apiAppResponse {
|
|
return apiAppResponse{
|
|
ID: a.ID,
|
|
Name: a.Name,
|
|
RepoURL: a.RepoURL,
|
|
Branch: a.Branch,
|
|
DockerfilePath: a.DockerfilePath,
|
|
Status: string(a.Status),
|
|
WebhookSecret: a.WebhookSecret,
|
|
SSHPublicKey: a.SSHPublicKey,
|
|
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
|
|
resp := apiDeploymentResponse{
|
|
ID: d.ID,
|
|
AppID: d.AppID,
|
|
Status: string(d.Status),
|
|
Duration: d.Duration(),
|
|
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
|
|
if d.CommitSHA.Valid {
|
|
resp.CommitSHA = d.CommitSHA.String
|
|
}
|
|
|
|
if d.FinishedAt.Valid {
|
|
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// HandleAPIListApps returns a handler that lists all apps as JSON.
|
|
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
apps, err := h.appService.ListApps(request.Context())
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to list apps"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]apiAppResponse, 0, len(apps))
|
|
for _, a := range apps {
|
|
result = append(result, appToAPI(a))
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPIGetApp returns a handler that gets a single app by ID.
|
|
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "internal server error"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPICreateApp returns a handler that creates a new app.
|
|
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
|
|
type createRequest struct {
|
|
Name string `json:"name"`
|
|
RepoURL string `json:"repoUrl"`
|
|
Branch string `json:"branch"`
|
|
DockerfilePath string `json:"dockerfilePath"`
|
|
DockerNetwork string `json:"dockerNetwork"`
|
|
NtfyTopic string `json:"ntfyTopic"`
|
|
SlackWebhook string `json:"slackWebhook"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
var req createRequest
|
|
|
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
|
if decodeErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid JSON body"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if req.Name == "" || req.RepoURL == "" {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "name and repo_url are required"},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
nameErr := validateAppName(req.Name)
|
|
if nameErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "invalid app name: " + nameErr.Error()},
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
|
|
Name: req.Name,
|
|
RepoURL: req.RepoURL,
|
|
Branch: req.Branch,
|
|
DockerfilePath: req.DockerfilePath,
|
|
DockerNetwork: req.DockerNetwork,
|
|
NtfyTopic: req.NtfyTopic,
|
|
SlackWebhook: req.SlackWebhook,
|
|
})
|
|
if createErr != nil {
|
|
h.log.Error("api: failed to create app", "error", createErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to create app"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
// HandleAPIDeleteApp returns a handler that deletes an app.
|
|
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "internal server error"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
deleteErr := h.appService.DeleteApp(request.Context(), application)
|
|
if deleteErr != nil {
|
|
h.log.Error("api: failed to delete app", "error", deleteErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to delete app"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"status": "deleted"}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// deploymentsPageLimit is the default number of deployments per page.
|
|
const deploymentsPageLimit = 20
|
|
|
|
// HandleAPIListDeployments returns a handler that lists deployments for an app.
|
|
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil || application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
limit := deploymentsPageLimit
|
|
|
|
if l := request.URL.Query().Get("limit"); l != "" {
|
|
parsed, parseErr := strconv.Atoi(l)
|
|
if parseErr == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
deployments, deployErr := application.GetDeployments(
|
|
request.Context(), limit,
|
|
)
|
|
if deployErr != nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to list deployments"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
result := make([]apiDeploymentResponse, 0, len(deployments))
|
|
for _, d := range deployments {
|
|
result = append(result, deploymentToAPI(d))
|
|
}
|
|
|
|
h.respondJSON(writer, request, result, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
|
|
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, err := h.appService.GetApp(request.Context(), appID)
|
|
if err != nil || application == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "app not found"},
|
|
http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
|
|
if deployErr != nil {
|
|
h.log.Error("api: failed to trigger deploy", "error", deployErr)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": deployErr.Error()},
|
|
http.StatusConflict)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"status": "deploying"}, http.StatusAccepted)
|
|
}
|
|
}
|
|
|
|
// HandleAPICreateToken returns a handler that creates an API token.
|
|
func (h *Handlers) HandleAPICreateToken() http.HandlerFunc {
|
|
type createTokenRequest struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type createTokenResponse struct {
|
|
Token string `json:"token"`
|
|
Name string `json:"name"`
|
|
ID int64 `json:"id"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
user := middleware.APIUserFromContext(request.Context())
|
|
if user == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "unauthorized"},
|
|
http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
var req createTokenRequest
|
|
|
|
decodeErr := json.NewDecoder(request.Body).Decode(&req)
|
|
if decodeErr != nil {
|
|
req.Name = "default"
|
|
}
|
|
|
|
if req.Name == "" {
|
|
req.Name = "default"
|
|
}
|
|
|
|
rawToken, token, err := models.GenerateAPIToken(
|
|
request.Context(), h.db, user.ID, req.Name,
|
|
)
|
|
if err != nil {
|
|
h.log.Error("api: failed to create token", "error", err)
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "failed to create token"},
|
|
http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, createTokenResponse{
|
|
Token: rawToken,
|
|
Name: token.Name,
|
|
ID: token.ID,
|
|
}, http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
|
|
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
|
|
type whoAmIResponse struct {
|
|
UserID int64 `json:"userId"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
user := middleware.APIUserFromContext(request.Context())
|
|
if user == nil {
|
|
h.respondJSON(writer, request,
|
|
map[string]string{"error": "unauthorized"},
|
|
http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, whoAmIResponse{
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
}, http.StatusOK)
|
|
}
|
|
}
|