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:
30
cmd/serve.go
Normal file
30
cmd/serve.go
Normal 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
194
serve/server.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user