phase 10
This commit is contained in:
+88
-6
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user