feat: add ax serve command with JSON API backed by NodeService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 13:04:29 +02:00
parent 9e5194893e
commit 3dfc46c3ff
5 changed files with 256 additions and 2 deletions

30
cmd/serve.go Normal file
View File

@@ -0,0 +1,30 @@
package cmd
import (
"axolotl/serve"
"axolotl/service"
"fmt"
"net/http"
"os"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the JSON API server",
Run: func(cmd *cobra.Command, args []string) {
sc := cfg.GetServerConfig()
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
handler := serve.New(service.GetNodeServiceForUser)
fmt.Fprintf(os.Stdout, "listening on %s\n", addr)
if err := http.ListenAndServe(addr, handler); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(serveCmd)
}

194
serve/server.go Normal file
View File

@@ -0,0 +1,194 @@
package serve
import (
"axolotl/models"
"axolotl/service"
"encoding/json"
"net/http"
"strings"
)
// New returns an HTTP handler that exposes NodeService as a JSON API.
// Every request must supply an X-Ax-User header identifying the acting user.
func New(newSvc func(user string) (service.NodeService, error)) http.Handler {
s := &server{newSvc: newSvc}
mux := http.NewServeMux()
mux.HandleFunc("GET /nodes", s.listNodes)
mux.HandleFunc("POST /nodes", s.addNode)
mux.HandleFunc("GET /nodes/{id}", s.getNode)
mux.HandleFunc("PATCH /nodes/{id}", s.updateNode)
mux.HandleFunc("DELETE /nodes/{id}", s.deleteNode)
mux.HandleFunc("GET /users", s.listUsers)
mux.HandleFunc("POST /users", s.addUser)
return mux
}
type server struct {
newSvc func(user string) (service.NodeService, error)
}
func (s *server) svc(w http.ResponseWriter, r *http.Request) (service.NodeService, bool) {
user := r.Header.Get("X-Ax-User")
if user == "" {
writeError(w, http.StatusUnauthorized, "X-Ax-User header required")
return nil, false
}
svc, err := s.newSvc(user)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return nil, false
}
return svc, true
}
func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
q := r.URL.Query()
var filter service.ListFilter
for _, tag := range q["tag"] {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(tag)})
}
for _, rel := range q["rel"] {
filter.Rels = append(filter.Rels, parseRel(rel))
}
for k, prefix := range map[string]string{"type": "_type::", "status": "_status::", "prio": "_prio::"} {
if v := q.Get(k); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType(prefix + v)})
}
}
if v := q.Get("namespace"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v})
}
if v := q.Get("assignee"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})
}
if v := q.Get("mention"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
}
nodes, err := svc.List(filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, nodes)
}
func (s *server) addNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
var input service.AddInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := svc.Add(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, n)
}
func (s *server) getNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
n, err := svc.GetByID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, n)
}
func (s *server) updateNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
var input service.UpdateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := svc.Update(r.PathValue("id"), input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, n)
}
func (s *server) deleteNode(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
if err := svc.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) listUsers(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
users, err := svc.ListUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, users)
}
func (s *server) addUser(w http.ResponseWriter, r *http.Request) {
svc, ok := s.svc(w, r)
if !ok {
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := svc.AddUser(body.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, n)
}
func parseRel(s string) service.RelInput {
if strings.Contains(s, "::") {
return service.RelInput{Type: models.RelType(s)}
}
if idx := strings.Index(s, ":"); idx >= 0 {
return service.RelInput{Type: models.RelType(s[:idx]), Target: s[idx+1:]}
}
return service.RelInput{Type: models.RelType(s)}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -6,6 +6,11 @@ type Alias struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
}
type Config interface { type Config interface {
GetUser() string GetUser() string
SetUser(username string) error SetUser(username string) error
@@ -13,5 +18,6 @@ type Config interface {
SetAlias(alias *Alias) error SetAlias(alias *Alias) error
DeleteAlias(name string) error DeleteAlias(name string) error
ListAliases() ([]*Alias, error) ListAliases() ([]*Alias, error)
GetServerConfig() ServerConfig
Save() error Save() error
} }

View File

@@ -13,6 +13,7 @@ type fileConfig struct {
path string path string
User string `json:"user"` User string `json:"user"`
UserAliases []*Alias `json:"aliases"` UserAliases []*Alias `json:"aliases"`
Serve ServerConfig `json:"serve"`
} }
var defaultAliases = []*Alias{ var defaultAliases = []*Alias{
@@ -140,6 +141,18 @@ func (c *fileConfig) ListAliases() ([]*Alias, error) {
return result, nil return result, nil
} }
func (c *fileConfig) GetServerConfig() ServerConfig {
host := c.Serve.Host
if host == "" {
host = "localhost"
}
port := c.Serve.Port
if port == 0 {
port = 7000
}
return ServerConfig{Host: host, Port: port}
}
func (c *fileConfig) Save() error { func (c *fileConfig) Save() error {
if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil {
return err return err

View File

@@ -82,3 +82,14 @@ func GetNodeService(cfg Config) (NodeService, error) {
} }
return &nodeServiceImpl{store: st, userID: user}, nil return &nodeServiceImpl{store: st, userID: user}, nil
} }
func GetNodeServiceForUser(user string) (NodeService, error) {
if user == "" {
return nil, fmt.Errorf("user is required")
}
st, err := store.FindAndOpenSQLiteStore()
if err != nil {
return nil, err
}
return &nodeServiceImpl{store: st, userID: user}, nil
}