512 lines
16 KiB
Go
512 lines
16 KiB
Go
// Package api implements the REST API handlers for uopi.
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"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
|
|
archiverURL 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, archiverURL string, log *slog.Logger) *Handler {
|
|
return &Handler{
|
|
broker: b,
|
|
synthetic: synth,
|
|
store: store,
|
|
channelFinderURL: channelFinderURL,
|
|
archiverURL: archiverURL,
|
|
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+"/archiver/search", h.archiverSearch)
|
|
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)
|
|
// Signal group tree
|
|
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
|
|
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
|
|
// 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"`
|
|
Properties map[string]string `json:"properties,omitempty"`
|
|
Tags []string `json:"tags,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 + "*"
|
|
} else if r.URL.RawQuery != "" && r.URL.RawQuery != "q=" {
|
|
cfURL += "?" + r.URL.RawQuery
|
|
} else {
|
|
// Empty query check: return empty results to signal CFS is configured.
|
|
jsonOK(w, []struct{}{})
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// ── /archiver/search ──────────────────────────────────────────────────────────
|
|
|
|
// archiverSearch proxies a search query to the EPICS Archive Appliance
|
|
// management API when configured. Returns 501 otherwise.
|
|
func (h *Handler) archiverSearch(w http.ResponseWriter, r *http.Request) {
|
|
if h.archiverURL == "" {
|
|
jsonError(w, http.StatusNotImplemented, "Archiver not configured (set archive_url in config)")
|
|
return
|
|
}
|
|
|
|
pattern := r.URL.Query().Get("q")
|
|
if pattern == "" {
|
|
pattern = "*"
|
|
}
|
|
// AA expects glob patterns. If no glob characters, wrap in stars.
|
|
if !strings.ContainsAny(pattern, "*?[]") {
|
|
pattern = "*" + pattern + "*"
|
|
}
|
|
|
|
searchURL := strings.TrimRight(h.archiverURL, "/") + "/mgmt/bpl/getAllPVs?pv=" + url.QueryEscape(pattern)
|
|
resp, err := http.Get(searchURL) //nolint:noctx
|
|
if err != nil {
|
|
jsonError(w, http.StatusBadGateway, "Archiver request failed: "+err.Error())
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
jsonError(w, http.StatusBadGateway, "Archiver returned status "+resp.Status)
|
|
return
|
|
}
|
|
|
|
var pvs []string
|
|
if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
|
|
jsonError(w, http.StatusBadGateway, "Archiver response parse error: "+err.Error())
|
|
return
|
|
}
|
|
|
|
jsonOK(w, pvs)
|
|
}
|
|
|
|
// ── /groups ───────────────────────────────────────────────────────────────────
|
|
|
|
// getGroups returns the workspace group tree as a JSON array.
|
|
func (h *Handler) getGroups(w http.ResponseWriter, r *http.Request) {
|
|
data, err := h.store.ReadGroups()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
// putGroups replaces the workspace group tree. The body must be a JSON array.
|
|
func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB max
|
|
if err != nil {
|
|
http.Error(w, "read error", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Validate it is a JSON array (basic sanity check).
|
|
var raw []json.RawMessage
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
http.Error(w, "body must be a JSON array", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.store.WriteGroups(body); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── /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,
|
|
Properties: m.Properties,
|
|
Tags: m.Tags,
|
|
}
|
|
}
|
|
|
|
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})
|
|
}
|