From 3dfc46c3ffb3899036fb02f63f33bb9e05d43af5 Mon Sep 17 00:00:00 2001 From: Elias Kohout Date: Wed, 1 Apr 2026 13:04:29 +0200 Subject: [PATCH] feat: add ax serve command with JSON API backed by NodeService Co-Authored-By: Claude Sonnet 4.6 --- cmd/serve.go | 30 +++++++ serve/server.go | 194 ++++++++++++++++++++++++++++++++++++++++ service/config.go | 6 ++ service/config_file.go | 17 +++- service/node_service.go | 11 +++ 5 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 cmd/serve.go create mode 100644 serve/server.go diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..c539a39 --- /dev/null +++ b/cmd/serve.go @@ -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) +} diff --git a/serve/server.go b/serve/server.go new file mode 100644 index 0000000..64827ae --- /dev/null +++ b/serve/server.go @@ -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}) +} diff --git a/service/config.go b/service/config.go index dd216e0..34f9dd6 100644 --- a/service/config.go +++ b/service/config.go @@ -6,6 +6,11 @@ type Alias struct { Description string `json:"description,omitempty"` } +type ServerConfig struct { + Host string `json:"host"` + Port int `json:"port"` +} + type Config interface { GetUser() string SetUser(username string) error @@ -13,5 +18,6 @@ type Config interface { SetAlias(alias *Alias) error DeleteAlias(name string) error ListAliases() ([]*Alias, error) + GetServerConfig() ServerConfig Save() error } diff --git a/service/config_file.go b/service/config_file.go index 86f7736..edfe654 100644 --- a/service/config_file.go +++ b/service/config_file.go @@ -11,8 +11,9 @@ import ( type fileConfig struct { path string - User string `json:"user"` - UserAliases []*Alias `json:"aliases"` + User string `json:"user"` + UserAliases []*Alias `json:"aliases"` + Serve ServerConfig `json:"serve"` } var defaultAliases = []*Alias{ @@ -140,6 +141,18 @@ func (c *fileConfig) ListAliases() ([]*Alias, error) { 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 { if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil { return err diff --git a/service/node_service.go b/service/node_service.go index 90b8b7d..c901116 100644 --- a/service/node_service.go +++ b/service/node_service.go @@ -82,3 +82,14 @@ func GetNodeService(cfg Config) (NodeService, error) { } 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 +}