feat: add JSON API with token auth (closes #69)
- 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
This commit is contained in:
372
internal/handlers/api.go
Normal file
372
internal/handlers/api.go
Normal file
@@ -0,0 +1,372 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user