This commit is contained in:
Martino Ferrari
2026-04-26 11:01:55 +02:00
parent 91b42027c9
commit e83e183673
17 changed files with 1009 additions and 182 deletions
+88 -6
View File
@@ -7,6 +7,7 @@ import (
"io"
"log/slog"
"net/http"
"strings"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
@@ -16,15 +17,16 @@ import (
// 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
log *slog.Logger
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, log *slog.Logger) *Handler {
return &Handler{broker: b, synthetic: synth, store: store, log: log}
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.
@@ -32,6 +34,8 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log
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)
@@ -100,6 +104,84 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
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) {
+3 -3
View File
@@ -10,14 +10,14 @@ import (
"github.com/uopi/uopi/internal/datasource/stub"
)
func newBroker(t *testing.T) (*broker.Broker, context.CancelFunc) {
t.Helper()
func newBroker(tb testing.TB) (*broker.Broker, context.CancelFunc) {
tb.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.Default()
b := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal(err)
tb.Fatal(err)
}
b.Register(ds)
return b, cancel
+7 -3
View File
@@ -20,9 +20,10 @@ type ServerConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
}
type SyntheticConfig struct {
@@ -75,6 +76,9 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" {
cfg.EPICS.ArchiveURL = v
}
if v := env("UOPI_EPICS_CHANNEL_FINDER_URL"); v != "" {
cfg.EPICS.ChannelFinderURL = v
}
if v := env("UOPI_SYNTHETIC_DEFINITIONS_FILE"); v != "" {
cfg.Synthetic.DefinitionsFile = v
}
+6 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/storage"
)
@@ -22,7 +23,7 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL string, log *slog.Logger) *Server {
mux := http.NewServeMux()
// Health check
@@ -34,8 +35,11 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
// REST API
api.New(brk, synth, store, log).Register(mux, apiPrefix)
api.New(brk, synth, store, channelFinderURL, log).Register(mux, apiPrefix)
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
+18
View File
@@ -13,6 +13,7 @@ import (
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
)
// ── Incoming message types ────────────────────────────────────────────────────
@@ -95,6 +96,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.log.Error("ws accept failed", "err", err)
return
}
metrics.IncWsConns()
defer metrics.DecWsConns()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
@@ -191,6 +194,7 @@ func (c *wsClient) writeLoop(ctx context.Context) {
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
return
}
metrics.IncMsgOut()
case <-ctx.Done():
return
}
@@ -199,6 +203,7 @@ func (c *wsClient) writeLoop(ctx context.Context) {
// handleMessage parses and routes a single incoming client message.
func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
metrics.IncMsgIn()
var msg inMsg
if err := json.Unmarshal(data, &msg); err != nil {
c.sendError(ctx, "PARSE_ERROR", "invalid JSON: "+err.Error())
@@ -264,6 +269,17 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
// Enforce write permission before touching the value payload.
meta, err := ds.GetMetadata(ctx, msg.Name)
if err != nil {
c.sendError(ctx, "NOT_FOUND", "unknown signal: "+msg.Name)
return
}
if !meta.Writable {
c.sendError(ctx, "NOT_WRITABLE", "signal is read-only: "+msg.Name)
return
}
// Decode the JSON value as the appropriate Go type.
var value any
if err := json.Unmarshal(msg.Value, &value); err != nil {
@@ -271,12 +287,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
metrics.IncWrites()
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.sendError(ctx, "WRITE_ERROR", err.Error())
}
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
metrics.IncHistoryReqs()
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
+28
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"time"
"unicode"
)
// ErrNotFound is returned when the requested interface does not exist.
@@ -35,6 +36,21 @@ func New(storageDir string) (*Store, error) {
return &Store{dir: dir}, nil
}
// validateID returns an error if id could be used for path traversal or is
// otherwise unsafe. Valid IDs contain only letters, digits, hyphens, and
// underscores.
func validateID(id string) error {
if id == "" {
return fmt.Errorf("interface ID must not be empty")
}
for _, r := range id {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
return fmt.Errorf("invalid character %q in interface ID", r)
}
}
return nil
}
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, id+".xml")
}
@@ -85,6 +101,9 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) {
// Get returns the raw XML bytes for the interface with the given ID.
func (s *Store) Get(id string) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
}
data, err := os.ReadFile(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
@@ -114,6 +133,9 @@ func (s *Store) Create(xmlData []byte) (string, error) {
// Update replaces the XML for an existing interface.
func (s *Store) Update(id string, xmlData []byte) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
@@ -122,6 +144,9 @@ func (s *Store) Update(id string, xmlData []byte) error {
// Delete removes the interface with the given ID.
func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
@@ -131,6 +156,9 @@ func (s *Store) Delete(id string) error {
// Clone duplicates an interface, assigning a new unique ID.
func (s *Store) Clone(srcID string) (string, error) {
if err := validateID(srcID); err != nil {
return "", ErrNotFound
}
data, err := s.Get(srcID)
if err != nil {
return "", err