// Package api implements the REST API handlers for uopi. package api import ( "encoding/json" "errors" "io" "log/slog" "net/http" "strings" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/storage" ) // Handler holds the dependencies shared across all API endpoints. type Handler struct { broker *broker.Broker synthetic *synthetic.Synthetic // nil if not enabled store *storage.Store channelFinderURL string // empty if not configured log *slog.Logger } // New creates an API Handler. synth may be nil if the synthetic DS is disabled. func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL string, log *slog.Logger) *Handler { return &Handler{broker: b, synthetic: synth, store: store, channelFinderURL: channelFinderURL, log: log} } // Register mounts all API routes onto mux under the given prefix. // Requires Go 1.22+ for method+path routing. func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources) mux.HandleFunc("GET "+prefix+"/signals", h.listSignals) mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals) mux.HandleFunc("GET "+prefix+"/channel-finder", h.channelFinder) mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces) mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface) mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface) mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface) mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface) mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface) // Synthetic signal CRUD mux.HandleFunc("GET "+prefix+"/synthetic", h.listSynthetic) mux.HandleFunc("POST "+prefix+"/synthetic", h.createSynthetic) mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) } // ── /datasources ────────────────────────────────────────────────────────────── type dsInfo struct { Name string `json:"name"` Status string `json:"status"` } func (h *Handler) listDataSources(w http.ResponseWriter, r *http.Request) { sources := h.broker.DataSources() out := make([]dsInfo, len(sources)) for i, ds := range sources { out[i] = dsInfo{Name: ds.Name(), Status: "connected"} } jsonOK(w, out) } // ── /signals ────────────────────────────────────────────────────────────────── type signalInfo struct { Name string `json:"name"` Type string `json:"type"` Unit string `json:"unit,omitempty"` Description string `json:"description,omitempty"` DisplayLow float64 `json:"displayLow"` DisplayHigh float64 `json:"displayHigh"` Writable bool `json:"writable"` EnumStrings []string `json:"enumStrings,omitempty"` } func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { dsName := r.URL.Query().Get("ds") if dsName == "" { jsonError(w, http.StatusBadRequest, "query parameter 'ds' is required") return } ds, ok := h.broker.Source(dsName) if !ok { jsonError(w, http.StatusNotFound, "unknown data source: "+dsName) return } metas, err := ds.ListSignals(r.Context()) if err != nil { h.log.Error("list signals", "ds", dsName, "err", err) jsonError(w, http.StatusInternalServerError, err.Error()) return } out := make([]signalInfo, len(metas)) for i, m := range metas { out[i] = metaToSignalInfo(m) } jsonOK(w, out) } // ── /signals/search ─────────────────────────────────────────────────────────── func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { q := strings.ToLower(r.URL.Query().Get("q")) dsName := r.URL.Query().Get("ds") var sources []datasource.DataSource if dsName != "" { ds, ok := h.broker.Source(dsName) if !ok { jsonError(w, http.StatusNotFound, "unknown data source: "+dsName) return } sources = []datasource.DataSource{ds} } else { sources = h.broker.DataSources() } var out []signalInfo for _, ds := range sources { metas, err := ds.ListSignals(r.Context()) if err != nil { continue } for _, m := range metas { if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) { si := metaToSignalInfo(m) out = append(out, si) } } } if out == nil { out = []signalInfo{} } jsonOK(w, out) } // ── /channel-finder ─────────────────────────────────────────────────────────── // channelFinder proxies a query to the EPICS Channel Finder service when one // is configured, returning a flat list of PV names. Returns 501 otherwise. func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) { if h.channelFinderURL == "" { jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)") return } q := r.URL.Query().Get("q") cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels" if q != "" { cfURL += "?~name=*" + q + "*" } resp, err := http.Get(cfURL) //nolint:noctx if err != nil { jsonError(w, http.StatusBadGateway, "Channel Finder request failed: "+err.Error()) return } defer resp.Body.Close() // Channel Finder returns JSON array of channel objects; extract names. var channels []struct { Name string `json:"name"` } if err := json.NewDecoder(resp.Body).Decode(&channels); err != nil { jsonError(w, http.StatusBadGateway, "Channel Finder response parse error: "+err.Error()) return } names := make([]string, 0, len(channels)) for _, ch := range channels { if ch.Name != "" { names = append(names, ch.Name) } } jsonOK(w, names) } // ── /interfaces ─────────────────────────────────────────────────────────────── func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { list, err := h.store.List() if err != nil { h.log.Error("list interfaces", "err", err) jsonError(w, http.StatusInternalServerError, err.Error()) return } jsonOK(w, list) } func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) if err != nil { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) return } id, err := h.store.Create(body) if err != nil { h.log.Error("create interface", "err", err) jsonError(w, http.StatusBadRequest, err.Error()) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) } func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") data, err := h.store.Get(id) if err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) } else { jsonError(w, http.StatusInternalServerError, err.Error()) } return } w.Header().Set("Content-Type", "application/xml") _, _ = w.Write(data) } func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) if err != nil { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) return } if err := h.store.Update(id, body); err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) } else { h.log.Error("update interface", "id", id, "err", err) jsonError(w, http.StatusInternalServerError, err.Error()) } return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := h.store.Delete(id); err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) } else { jsonError(w, http.StatusInternalServerError, err.Error()) } return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") newID, err := h.store.Clone(id) if err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) } else { h.log.Error("clone interface", "id", id, "err", err) jsonError(w, http.StatusInternalServerError, err.Error()) } return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": newID}) } // ── /synthetic ──────────────────────────────────────────────────────────────── func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) { if h.synthetic == nil { jsonOK(w, []any{}) return } jsonOK(w, h.synthetic.GetDefs()) } func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) { if h.synthetic == nil { jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") return } var def synthetic.SignalDef if err := json.NewDecoder(r.Body).Decode(&def); err != nil { jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if err := h.synthetic.AddSignal(def); err != nil { if errors.Is(err, datasource.ErrNotFound) { jsonError(w, http.StatusConflict, err.Error()) } else { jsonError(w, http.StatusBadRequest, err.Error()) } return } w.WriteHeader(http.StatusCreated) jsonOK(w, def) } func (h *Handler) getSynthetic(w http.ResponseWriter, r *http.Request) { if h.synthetic == nil { jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") return } name := r.PathValue("name") def, err := h.synthetic.GetSignal(name) if err != nil { if errors.Is(err, datasource.ErrNotFound) { jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) } else { jsonError(w, http.StatusInternalServerError, err.Error()) } return } jsonOK(w, def) } func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) { if h.synthetic == nil { jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") return } name := r.PathValue("name") var def synthetic.SignalDef if err := json.NewDecoder(r.Body).Decode(&def); err != nil { jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } // Ensure the name in the URL matches the body (or fill it in). def.Name = name if err := h.synthetic.UpdateSignal(def); err != nil { if errors.Is(err, datasource.ErrNotFound) { jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) } else { jsonError(w, http.StatusBadRequest, err.Error()) } return } jsonOK(w, def) } func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) { if h.synthetic == nil { jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") return } name := r.PathValue("name") if err := h.synthetic.RemoveSignal(name); err != nil { if errors.Is(err, datasource.ErrNotFound) { jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) } else { jsonError(w, http.StatusInternalServerError, err.Error()) } return } w.WriteHeader(http.StatusNoContent) } // ── Helpers ─────────────────────────────────────────────────────────────────── func metaToSignalInfo(m datasource.Metadata) signalInfo { return signalInfo{ Name: m.Name, Type: dataTypeName(m.Type), Unit: m.Unit, Description: m.Description, DisplayLow: m.DisplayLow, DisplayHigh: m.DisplayHigh, Writable: m.Writable, EnumStrings: m.EnumStrings, } } func dataTypeName(t datasource.DataType) string { switch t { case datasource.TypeFloat64: return "float64" case datasource.TypeFloat64Array: return "float64[]" case datasource.TypeString: return "string" case datasource.TypeInt64: return "int64" case datasource.TypeBool: return "bool" case datasource.TypeEnum: return "enum" default: return "unknown" } } func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func jsonError(w http.ResponseWriter, code int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) }