From bf993a5945c16e7a77384503ae3ccd0455dac389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Sun, 22 Mar 2026 16:03:21 +0100 Subject: [PATCH] Baue Layout-Resolver und lokale Entwicklungsgerueste aus --- .gitignore | 18 ++ DEVELOPMENT.md | 31 +++ README.md | 8 + ansible/README.md | 5 + ansible/inventory.example.yml | 16 ++ ansible/site.yml | 7 + compose/README.md | 12 + compose/mosquitto.conf | 2 + compose/server-stack.yml | 21 ++ docs/LAYOUT-JSON.md | 213 ++++++++++++++++++ docs/TEMPLATE-KONZEPT.md | 2 + player/README.md | 1 + player/agent/Dockerfile | 10 + player/agent/README.md | 5 + player/agent/internal/app/app.go | 26 ++- player/agent/internal/config/config.go | 65 +++++- player/agent/internal/config/config_test.go | 42 ++++ player/config/config.example.json | 6 + server/backend/Dockerfile | 11 + server/backend/README.md | 10 + server/backend/internal/app/app.go | 8 +- .../campaigns/messagewall/resolver.go | 131 +++++++++++ .../campaigns/messagewall/resolver_test.go | 62 +++++ .../internal/campaigns/messagewall/types.go | 54 +++++ server/backend/internal/httpapi/errors.go | 31 +++ .../backend/internal/httpapi/messagewall.go | 23 ++ .../internal/httpapi/messagewall_test.go | 19 ++ server/backend/internal/httpapi/meta.go | 21 ++ server/backend/internal/httpapi/response.go | 13 ++ server/backend/internal/httpapi/router.go | 17 +- 30 files changed, 865 insertions(+), 25 deletions(-) create mode 100644 .gitignore create mode 100644 ansible/inventory.example.yml create mode 100644 ansible/site.yml create mode 100644 compose/README.md create mode 100644 compose/mosquitto.conf create mode 100644 compose/server-stack.yml create mode 100644 docs/LAYOUT-JSON.md create mode 100644 player/agent/Dockerfile create mode 100644 player/agent/internal/config/config_test.go create mode 100644 player/config/config.example.json create mode 100644 server/backend/Dockerfile create mode 100644 server/backend/internal/campaigns/messagewall/resolver.go create mode 100644 server/backend/internal/campaigns/messagewall/resolver_test.go create mode 100644 server/backend/internal/campaigns/messagewall/types.go create mode 100644 server/backend/internal/httpapi/errors.go create mode 100644 server/backend/internal/httpapi/messagewall.go create mode 100644 server/backend/internal/httpapi/messagewall_test.go create mode 100644 server/backend/internal/httpapi/meta.go create mode 100644 server/backend/internal/httpapi/response.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11f5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Go build artefacts +bin/ +dist/ +*.exe +*.test +*.out + +# Editor and OS junk +.DS_Store +.idea/ +.vscode/ + +# Local environment files +.env +.env.* + +# Compose override files +compose.override.yml diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ba50ae..f2ae241 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -61,6 +61,11 @@ Spaeter zusaetzlich sinnvoll: - `postgresql-client` - `mosquitto-clients` +Fuer Container-Builds liegen erste Dockerfiles in: + +- `server/backend/Dockerfile` +- `player/agent/Dockerfile` + ## Schnellstart Repository klonen: @@ -77,6 +82,7 @@ Dokumentationsbasis lesen: 3. `TECH-STACK.md` 4. `docs/SCHEMA.md` 5. `docs/OFFENE-ARCHITEKTURFRAGEN.md` +6. `docs/LAYOUT-JSON.md` ## Build-Kommandos @@ -100,6 +106,19 @@ go build ./... make build ``` +Aktuell bedeutet das: + +- `make build` baut Backend und Agent +- `make build-backend` baut nur `server/backend` +- `make build-agent` baut nur `player/agent` +- `make run-backend` startet das Backend +- `make run-agent` startet den Agenten +- `make fmt` formatiert beide Go-Module + +Hinweis: + +- auf dem aktuellen System dieser Session sind `make` und `go` nicht installiert; die Befehle sind fuer den Entwicklungsrechner vorbereitet + ## Lokaler Start ### Backend lokal starten @@ -138,6 +157,12 @@ Standardwerte: - `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080` - `MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883` +Optional dateibasiert: + +- `MORZ_INFOBOARD_CONFIG=/etc/signage/config.json` + +Eine Beispielkonfiguration liegt in `player/config/config.example.json`. + Beispiel: ```bash @@ -163,6 +188,12 @@ go run ./cmd/agent 4. Agent: strukturierte Logs und Health-Modell einziehen 5. Danach erst DB-, MQTT- und API-Funktionalitaet ausbauen +Ergaenzt seit dem ersten Geruest: + +- `message_wall`-Resolver im Backend +- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides +- lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto + ## Arbeitsweise Empfohlen: diff --git a/README.md b/README.md index f3404c1..1a7224f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit: - Technologieentscheidungen: `TECH-STACK.md` - Entwicklungsleitfaden: `DEVELOPMENT.md` - Template-/Kampagnenkonzept: `docs/TEMPLATE-KONZEPT.md` +- `layout_json` fuer `message_wall`: `docs/LAYOUT-JSON.md` - Provisionierungskonzept: `docs/PROVISIONIERUNGSKONZEPT.md` - Player-Konzept: `docs/PLAYER-KONZEPT.md` - Server-Konzept: `docs/SERVER-KONZEPT.md` @@ -32,6 +33,13 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit: - `compose/` fuer Container-Definitionen und Stack-Bausteine - `scripts/` fuer Hilfsskripte +## Aktueller Implementierungsstand + +- `server/backend/` enthaelt ein lauffaehiges Go-Grundgeruest mit erster Tool-API fuer `message_wall` +- `player/agent/` enthaelt ein Go-Grundgeruest mit dateibasierter und env-basierter Konfiguration +- `compose/` enthaelt ein lokales Grundgeruest fuer PostgreSQL und Mosquitto +- `ansible/` enthaelt erste Platzhalter fuer Inventory und Playbook-Struktur + ## Naechste sinnvolle Inhalte in der Struktur - `docs/` fuer weitere technische Detaildokumente diff --git a/ansible/README.md b/ansible/README.md index aa5defe..5dd174a 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -12,3 +12,8 @@ Naechster geplanter Ausbau: - Rolle `signage_provision` - Rolle `signage_player` - Beispiel-Inventories fuer Wand- und Einzelanzeigen + +Aktuell vorhanden: + +- `inventory.example.yml` +- `site.yml` als Platzhalter-Playbook diff --git a/ansible/inventory.example.yml b/ansible/inventory.example.yml new file mode 100644 index 0000000..2372f52 --- /dev/null +++ b/ansible/inventory.example.yml @@ -0,0 +1,16 @@ +all: + children: + info_wall: + hosts: + info01: + ansible_host: 10.0.0.101 + screen_id: info01 + orientation: portrait + rotation: 90 + vertretungsplan: + hosts: + vplan01: + ansible_host: 10.0.1.101 + screen_id: vplan01 + orientation: landscape + rotation: 0 diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..ba0a1dd --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,7 @@ +- name: Placeholder deployment entrypoint + hosts: all + gather_facts: false + tasks: + - name: Show target host placeholder + ansible.builtin.debug: + msg: "Placeholder fuer spaeteres signage deployment auf {{ inventory_hostname }}" diff --git a/compose/README.md b/compose/README.md new file mode 100644 index 0000000..8916567 --- /dev/null +++ b/compose/README.md @@ -0,0 +1,12 @@ +# Compose + +Hier liegen lokale und spaetere produktionsnahe Compose-Bausteine fuer den zentralen Server-Stack. + +Aktuell vorhanden: + +- `server-stack.yml` als fruehes lokales Grundgeruest fuer PostgreSQL und Mosquitto + +Geplanter naechster Ausbau: + +- Backend-Service mit lokalem Dockerfile +- spaeter Reverse Proxy, Worker und persistente Konfigurationspfade diff --git a/compose/mosquitto.conf b/compose/mosquitto.conf new file mode 100644 index 0000000..c8348ac --- /dev/null +++ b/compose/mosquitto.conf @@ -0,0 +1,2 @@ +listener 1883 +allow_anonymous true diff --git a/compose/server-stack.yml b/compose/server-stack.yml new file mode 100644 index 0000000..3fd1509 --- /dev/null +++ b/compose/server-stack.yml @@ -0,0 +1,21 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: morz_infoboard + POSTGRES_USER: morz_infoboard + POSTGRES_PASSWORD: morz_infoboard + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + + mosquitto: + image: eclipse-mosquitto:2 + ports: + - "1883:1883" + volumes: + - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + +volumes: + postgres-data: diff --git a/docs/LAYOUT-JSON.md b/docs/LAYOUT-JSON.md new file mode 100644 index 0000000..a33da71 --- /dev/null +++ b/docs/LAYOUT-JSON.md @@ -0,0 +1,213 @@ +# Info-Board Neu - `layout_json` fuer `message_wall` + +## Ziel + +Dieses Dokument definiert das `layout_json` fuer `message_wall` verbindlich fuer v1. + +Es beschreibt nicht die eigentliche Mediendatei, sondern die serverseitige Segmentlogik, mit der aus einem Gesamtmotiv konkrete Screen-Szenen erzeugt werden. + +## Grundentscheidung fuer v1 + +- `message_wall` wird serverseitig aufgeloest +- der Player bekommt nur fertige Screen-Szenen +- `layout_json` wird vom Backend oder Worker interpretiert +- der Player berechnet keine Ausschnitte ueber mehrere Displays hinweg + +## V1-Modell + +Das `layout_json` beschreibt ein virtuelles Gesamtkoordinatensystem und eine Menge von Slots. + +Jeder Slot repraesentiert einen Zielbereich im Gesamtmotiv. + +## Struktur + +```json +{ + "version": 1, + "coordinate_space": { + "width": 300, + "height": 300, + "unit": "grid" + }, + "fit_mode": "cover", + "slots": [ + { + "slot_id": "wall-r1-c1", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + { + "slot_id": "wall-r1-c2", + "x": 100, + "y": 0, + "width": 100, + "height": 100 + } + ] +} +``` + +## Felder + +### `version` + +- aktuell fest `1` +- erlaubt spaetere Weiterentwicklung ohne uneindeutiges Parsing + +### `coordinate_space` + +Definiert den virtuellen Gesamtraum. + +Felder: + +- `width` +- `height` +- `unit` + +Fuer v1 gilt: + +- `unit` ist immer `grid` +- `width` und `height` sind positive Ganzzahlen + +Interpretation: + +- das Gesamtmotiv wird in diesem virtuellen Raum beschrieben +- Slots schneiden daraus Teilbereiche aus + +### `fit_mode` + +Definiert, wie das Ausgangsmedium auf den virtuellen Gesamtraum bezogen wird. + +Zulaessige Werte fuer v1: + +- `cover` +- `contain` + +V1-Standard: + +- `cover` + +Interpretation: + +- `cover`: das Motiv fuellt den Gesamtraum vollstaendig, mit moeglichem Beschnitt +- `contain`: das Motiv bleibt vollstaendig sichtbar, mit moeglichen Randflaechen + +### `slots` + +Liste aller Zielslots innerhalb des virtuellen Gesamtraums. + +Jeder Slot hat: + +- `slot_id` +- `x` +- `y` +- `width` +- `height` + +Regeln: + +- `slot_id` ist innerhalb eines Layouts eindeutig +- `x`, `y`, `width`, `height` sind Ganzzahlen im virtuellen Koordinatensystem +- `width > 0` und `height > 0` +- Slots duerfen sich in v1 nicht ueberlappen +- Slots muessen vollstaendig innerhalb des `coordinate_space` liegen + +## Semantik + +Ein Slot beschreibt den Ausschnitt, den ein bestimmter Screen spaeter bekommt. + +Das Backend oder der Worker erzeugt daraus pro Zielscreen eine konkrete Scene mit: + +- Zielscreen oder Zielslot +- Originalmedium +- berechnetem Crop-Bereich +- Dauer, Timeout und Fehlerverhalten + +## Beispiel 3x3-Infowand + +```json +{ + "version": 1, + "coordinate_space": { + "width": 300, + "height": 300, + "unit": "grid" + }, + "fit_mode": "cover", + "slots": [ + { "slot_id": "wall-r1-c1", "x": 0, "y": 0, "width": 100, "height": 100 }, + { "slot_id": "wall-r1-c2", "x": 100, "y": 0, "width": 100, "height": 100 }, + { "slot_id": "wall-r1-c3", "x": 200, "y": 0, "width": 100, "height": 100 }, + { "slot_id": "wall-r2-c1", "x": 0, "y": 100, "width": 100, "height": 100 }, + { "slot_id": "wall-r2-c2", "x": 100, "y": 100, "width": 100, "height": 100 }, + { "slot_id": "wall-r2-c3", "x": 200, "y": 100, "width": 100, "height": 100 }, + { "slot_id": "wall-r3-c1", "x": 0, "y": 200, "width": 100, "height": 100 }, + { "slot_id": "wall-r3-c2", "x": 100, "y": 200, "width": 100, "height": 100 }, + { "slot_id": "wall-r3-c3", "x": 200, "y": 200, "width": 100, "height": 100 } + ] +} +``` + +## Ergebnis der serverseitigen Aufloesung + +Aus einem `message_wall`-Template mit diesem `layout_json` entstehen konkrete Screen-Szenen. + +Jede resultierende Szene enthaelt fachlich mindestens: + +- `slot_id` +- `src` +- `crop` +- `fit_mode` +- `duration_seconds` +- `load_timeout_seconds` +- `on_error` + +Der `crop` beschreibt dann genau den Ausschnitt fuer diesen Slot. + +## Warum Grid statt Pixel? + +Fuer v1 wird absichtlich ein abstrahiertes Grid-Modell verwendet. + +Vorteile: + +- leicht lesbar und dokumentierbar +- unabhaengiger von konkreten Asset-Abmessungen +- einfach im Backend validierbar +- gut fuer Admin-Vorschau und Segmentlogik + +Pixelgenaue Berechnung kann spaeter intern aus dem Grid abgeleitet werden. + +## Validierungsregeln fuer das Backend + +Das Backend oder der Worker muss mindestens pruefen: + +- `version == 1` +- `coordinate_space.width > 0` +- `coordinate_space.height > 0` +- `unit == grid` +- `fit_mode` ist erlaubt +- keine doppelten `slot_id` +- keine Slots ausserhalb des Gesamtraums +- keine ueberlappenden Slots + +## V1-Abgrenzung + +Nicht Teil von v1: + +- rotierte Slots +- verschachtelte Layouts +- freie Polygonformen +- playerseitige Segmentlogik +- automatische Textsatz-Engine im Layout selbst + +## Bezug zur Implementierung + +Die erste Backend-Implementierung soll aus diesem `layout_json` eine Liste konkreter Screen-Szenen ableiten koennen. + +Dabei gilt: + +- das Input-Modell bleibt bewusst einfach +- die Aufloesung ist serverseitige Fachlogik +- der Player bekommt nur bereits aufgeloeste Einzelinhalte diff --git a/docs/TEMPLATE-KONZEPT.md b/docs/TEMPLATE-KONZEPT.md index 321eb46..d6fe267 100644 --- a/docs/TEMPLATE-KONZEPT.md +++ b/docs/TEMPLATE-KONZEPT.md @@ -288,3 +288,5 @@ Vor der UI- und Render-Implementierung gilt: - `message_wall` wird serverseitig in konkrete Zielinhalte aufgeloest - `layout_json` beschreibt die serverseitige Segmentlogik und Admin-Vorschau - Slots werden geometrisch serverseitig interpretiert, nicht im Player berechnet + +Details dazu stehen in `docs/LAYOUT-JSON.md`. diff --git a/player/README.md b/player/README.md index b926f17..4bd0aae 100644 --- a/player/README.md +++ b/player/README.md @@ -13,3 +13,4 @@ Geplant: Aktuell vorhanden: - `agent/` mit erstem Go-Geruest +- `config/config.example.json` als lokale Beispielkonfiguration diff --git a/player/agent/Dockerfile b/player/agent/Dockerfile new file mode 100644 index 0000000..cf471fc --- /dev/null +++ b/player/agent/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.24-alpine AS build + +WORKDIR /src +COPY . . +RUN go build -o /out/agent ./cmd/agent + +FROM alpine:3.22 +WORKDIR /app +COPY --from=build /out/agent /usr/local/bin/agent +ENTRYPOINT ["/usr/local/bin/agent"] diff --git a/player/agent/README.md b/player/agent/README.md index 0c0bfbf..55b8d17 100644 --- a/player/agent/README.md +++ b/player/agent/README.md @@ -14,3 +14,8 @@ Geplante Unterstruktur: - `cmd/agent/` fuer den Startpunkt - `internal/app/` fuer Initialisierung und Laufzeit - `internal/config/` fuer Konfiguration + +Aktuell vorhanden: + +- Env-basierte und dateibasierte Konfiguration +- einfacher Laufzeit-Loop mit Heartbeat-Ticks im Log diff --git a/player/agent/internal/app/app.go b/player/agent/internal/app/app.go index 377be9a..8506daf 100644 --- a/player/agent/internal/app/app.go +++ b/player/agent/internal/app/app.go @@ -2,6 +2,8 @@ package app import ( "fmt" + "log" + "os" "time" "git.az-it.net/az/morz-infoboard/player/agent/internal/config" @@ -9,20 +11,32 @@ import ( type App struct { Config config.Config + logger *log.Logger } func New() (*App, error) { - cfg := config.Load() - - if cfg.ScreenID == "" { - return nil, fmt.Errorf("screen id is required") + cfg, err := config.Load() + if err != nil { + return nil, err } - return &App{Config: cfg}, nil + logger := log.New(os.Stdout, "agent ", log.LstdFlags|log.LUTC) + + return &App{Config: cfg, logger: logger}, nil } func (a *App) Run() error { + if a.Config.ScreenID == "" { + return fmt.Errorf("screen id is required") + } + + a.logger.Printf("configured server=%s mqtt=%s heartbeat=%ds", a.Config.ServerBaseURL, a.Config.MQTTBroker, a.Config.HeartbeatEvery) + + ticker := time.NewTicker(time.Duration(a.Config.HeartbeatEvery) * time.Second) + defer ticker.Stop() + for { - time.Sleep(30 * time.Second) + a.logger.Printf("heartbeat tick screen=%s", a.Config.ScreenID) + <-ticker.C } } diff --git a/player/agent/internal/config/config.go b/player/agent/internal/config/config.go index b1e6c7c..dd05499 100644 --- a/player/agent/internal/config/config.go +++ b/player/agent/internal/config/config.go @@ -1,19 +1,66 @@ package config -import "os" +import ( + "encoding/json" + "fmt" + "os" +) type Config struct { - ScreenID string - ServerBaseURL string - MQTTBroker string + ScreenID string `json:"screen_id"` + ServerBaseURL string `json:"server_base_url"` + MQTTBroker string `json:"mqtt_broker"` + HeartbeatEvery int `json:"heartbeat_every_seconds"` } -func Load() Config { - return Config{ - ScreenID: getenv("MORZ_INFOBOARD_SCREEN_ID", "unset-screen"), - ServerBaseURL: getenv("MORZ_INFOBOARD_SERVER_URL", "http://127.0.0.1:8080"), - MQTTBroker: getenv("MORZ_INFOBOARD_MQTT_BROKER", "tcp://127.0.0.1:1883"), +const defaultConfigPath = "/etc/signage/config.json" + +func Load() (Config, error) { + cfg := defaultConfig() + + configPath := getenv("MORZ_INFOBOARD_CONFIG", defaultConfigPath) + if err := loadFromFile(configPath, &cfg); err != nil { + if !os.IsNotExist(err) { + return Config{}, fmt.Errorf("load config file: %w", err) + } } + + overrideFromEnv(&cfg) + + if cfg.ScreenID == "" { + return Config{}, fmt.Errorf("screen id is required") + } + + if cfg.HeartbeatEvery <= 0 { + cfg.HeartbeatEvery = defaultConfig().HeartbeatEvery + } + + return cfg, nil +} + + +func defaultConfig() Config { + return Config{ + ScreenID: "unset-screen", + ServerBaseURL: "http://127.0.0.1:8080", + MQTTBroker: "tcp://127.0.0.1:1883", + HeartbeatEvery: 30, + } +} + +func loadFromFile(path string, cfg *Config) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return json.Unmarshal(data, cfg) +} + +func overrideFromEnv(cfg *Config) { + cfg.ScreenID = getenv("MORZ_INFOBOARD_SCREEN_ID", cfg.ScreenID) + cfg.ServerBaseURL = getenv("MORZ_INFOBOARD_SERVER_URL", cfg.ServerBaseURL) + cfg.MQTTBroker = getenv("MORZ_INFOBOARD_MQTT_BROKER", cfg.MQTTBroker) } func getenv(key, fallback string) string { diff --git a/player/agent/internal/config/config_test.go b/player/agent/internal/config/config_test.go new file mode 100644 index 0000000..5663307 --- /dev/null +++ b/player/agent/internal/config/config_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadReadsFileAndEnvOverrides(t *testing.T) { + t.Setenv("MORZ_INFOBOARD_CONFIG", filepath.Join(t.TempDir(), "config.json")) + configPath := os.Getenv("MORZ_INFOBOARD_CONFIG") + + content := []byte(`{ + "screen_id": "file-screen", + "server_base_url": "http://file.example", + "mqtt_broker": "tcp://file-broker:1883", + "heartbeat_every_seconds": 45 + }`) + + if err := os.WriteFile(configPath, content, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + t.Setenv("MORZ_INFOBOARD_SCREEN_ID", "env-screen") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got, want := cfg.ScreenID, "env-screen"; got != want { + t.Fatalf("ScreenID = %q, want %q", got, want) + } + + if got, want := cfg.ServerBaseURL, "http://file.example"; got != want { + t.Fatalf("ServerBaseURL = %q, want %q", got, want) + } + + if got, want := cfg.HeartbeatEvery, 45; got != want { + t.Fatalf("HeartbeatEvery = %d, want %d", got, want) + } +} diff --git a/player/config/config.example.json b/player/config/config.example.json new file mode 100644 index 0000000..3003577 --- /dev/null +++ b/player/config/config.example.json @@ -0,0 +1,6 @@ +{ + "screen_id": "info01-dev", + "server_base_url": "http://127.0.0.1:8080", + "mqtt_broker": "tcp://127.0.0.1:1883", + "heartbeat_every_seconds": 30 +} diff --git a/server/backend/Dockerfile b/server/backend/Dockerfile new file mode 100644 index 0000000..15d14b6 --- /dev/null +++ b/server/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.24-alpine AS build + +WORKDIR /src +COPY . . +RUN go build -o /out/backend ./cmd/api + +FROM alpine:3.22 +WORKDIR /app +COPY --from=build /out/backend /usr/local/bin/backend +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/backend"] diff --git a/server/backend/README.md b/server/backend/README.md index 6f20860..4ddde7e 100644 --- a/server/backend/README.md +++ b/server/backend/README.md @@ -7,10 +7,20 @@ Ziel fuer die erste Ausbaustufe: - HTTP-API in Go - Health-Endpunkt - saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule +- erste serverseitige Aufloesungslogik fuer `message_wall` Geplante Unterstruktur: - `cmd/api/` fuer den API-Startpunkt - `internal/app/` fuer App-Initialisierung +- `internal/campaigns/` fuer Kampagnen- und Template-Logik - `internal/httpapi/` fuer HTTP-Routing und Handler - `internal/config/` fuer Konfiguration + +Aktuell vorhanden: + +- `GET /healthz` +- `GET /api/v1` +- `GET /api/v1/meta` +- `POST /api/v1/tools/message-wall/resolve` als erste serverseitige Layout-Aufloesung fuer `message_wall` +- einheitliches API-Fehlerformat im HTTP-Layer diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index e2628ee..2053244 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -1,6 +1,7 @@ package app import ( + "errors" "net/http" "git.az-it.net/az/morz-infoboard/server/backend/internal/config" @@ -25,5 +26,10 @@ func New() (*App, error) { } func (a *App) Run() error { - return a.server.ListenAndServe() + err := a.server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return err } diff --git a/server/backend/internal/campaigns/messagewall/resolver.go b/server/backend/internal/campaigns/messagewall/resolver.go new file mode 100644 index 0000000..8222cf9 --- /dev/null +++ b/server/backend/internal/campaigns/messagewall/resolver.go @@ -0,0 +1,131 @@ +package messagewall + +import ( + "fmt" + "sort" +) + +const ( + versionV1 = 1 + unitGrid = "grid" + fitModeCover = "cover" + fitModeContain = "contain" + defaultDuration = 20 + defaultLoadTime = 15 + defaultOnError = "skip" +) + +func Resolve(request ResolveRequest) (ResolveResult, error) { + if err := Validate(request.Layout); err != nil { + return ResolveResult{}, err + } + + result := ResolveResult{ + Version: request.Layout.Version, + CoordinateSpace: request.Layout.CoordinateSpace, + FitMode: request.Layout.FitMode, + Scenes: make([]ResolvedScene, 0, len(request.Layout.Slots)), + } + + duration := request.DurationSeconds + if duration <= 0 { + duration = defaultDuration + } + + loadTimeout := request.LoadTimeoutSeconds + if loadTimeout <= 0 { + loadTimeout = defaultLoadTime + } + + onError := request.OnError + if onError == "" { + onError = defaultOnError + } + + for _, slot := range request.Layout.Slots { + result.Scenes = append(result.Scenes, ResolvedScene{ + SlotID: slot.SlotID, + Source: request.Source, + DurationSeconds: duration, + LoadTimeoutSeconds: loadTimeout, + OnError: onError, + Crop: Crop{ + X: slot.X, + Y: slot.Y, + Width: slot.Width, + Height: slot.Height, + Unit: request.Layout.CoordinateSpace.Unit, + }, + }) + } + + sort.Slice(result.Scenes, func(i, j int) bool { + return result.Scenes[i].SlotID < result.Scenes[j].SlotID + }) + + return result, nil +} + +func Validate(layout Layout) error { + if layout.Version != versionV1 { + return fmt.Errorf("unsupported layout version: %d", layout.Version) + } + + if layout.CoordinateSpace.Width <= 0 || layout.CoordinateSpace.Height <= 0 { + return fmt.Errorf("coordinate_space width and height must be positive") + } + + if layout.CoordinateSpace.Unit != unitGrid { + return fmt.Errorf("unsupported coordinate_space unit: %s", layout.CoordinateSpace.Unit) + } + + if layout.FitMode != fitModeCover && layout.FitMode != fitModeContain { + return fmt.Errorf("unsupported fit_mode: %s", layout.FitMode) + } + + if len(layout.Slots) == 0 { + return fmt.Errorf("layout must contain at least one slot") + } + + seen := make(map[string]struct{}, len(layout.Slots)) + for i, slot := range layout.Slots { + if slot.SlotID == "" { + return fmt.Errorf("slot %d has empty slot_id", i) + } + + if _, ok := seen[slot.SlotID]; ok { + return fmt.Errorf("duplicate slot_id: %s", slot.SlotID) + } + seen[slot.SlotID] = struct{}{} + + if slot.Width <= 0 || slot.Height <= 0 { + return fmt.Errorf("slot %s must have positive width and height", slot.SlotID) + } + + if slot.X < 0 || slot.Y < 0 { + return fmt.Errorf("slot %s must not start outside coordinate space", slot.SlotID) + } + + if slot.X+slot.Width > layout.CoordinateSpace.Width { + return fmt.Errorf("slot %s exceeds coordinate_space width", slot.SlotID) + } + + if slot.Y+slot.Height > layout.CoordinateSpace.Height { + return fmt.Errorf("slot %s exceeds coordinate_space height", slot.SlotID) + } + } + + for i := range layout.Slots { + for j := i + 1; j < len(layout.Slots); j++ { + if overlaps(layout.Slots[i], layout.Slots[j]) { + return fmt.Errorf("slots %s and %s overlap", layout.Slots[i].SlotID, layout.Slots[j].SlotID) + } + } + } + + return nil +} + +func overlaps(a, b Slot) bool { + return a.X < b.X+b.Width && a.X+a.Width > b.X && a.Y < b.Y+b.Height && a.Y+a.Height > b.Y +} diff --git a/server/backend/internal/campaigns/messagewall/resolver_test.go b/server/backend/internal/campaigns/messagewall/resolver_test.go new file mode 100644 index 0000000..f5aad22 --- /dev/null +++ b/server/backend/internal/campaigns/messagewall/resolver_test.go @@ -0,0 +1,62 @@ +package messagewall + +import "testing" + +func TestResolveBuildsScenes(t *testing.T) { + request := ResolveRequest{ + Layout: Layout{ + Version: 1, + CoordinateSpace: CoordinateSpace{ + Width: 200, + Height: 100, + Unit: "grid", + }, + FitMode: "cover", + Slots: []Slot{ + {SlotID: "wall-r1-c2", X: 100, Y: 0, Width: 100, Height: 100}, + {SlotID: "wall-r1-c1", X: 0, Y: 0, Width: 100, Height: 100}, + }, + }, + Source: "/media/banner.png", + DurationSeconds: 30, + LoadTimeoutSeconds: 12, + OnError: "skip", + } + + result, err := Resolve(request) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + if got, want := len(result.Scenes), 2; got != want { + t.Fatalf("len(result.Scenes) = %d, want %d", got, want) + } + + if got, want := result.Scenes[0].SlotID, "wall-r1-c1"; got != want { + t.Fatalf("first SlotID = %s, want %s", got, want) + } + + if got, want := result.Scenes[1].Crop.X, 100; got != want { + t.Fatalf("second Crop.X = %d, want %d", got, want) + } +} + +func TestValidateRejectsOverlappingSlots(t *testing.T) { + layout := Layout{ + Version: 1, + CoordinateSpace: CoordinateSpace{ + Width: 100, + Height: 100, + Unit: "grid", + }, + FitMode: "cover", + Slots: []Slot{ + {SlotID: "a", X: 0, Y: 0, Width: 60, Height: 60}, + {SlotID: "b", X: 50, Y: 0, Width: 50, Height: 50}, + }, + } + + if err := Validate(layout); err == nil { + t.Fatal("Validate() error = nil, want overlap error") + } +} diff --git a/server/backend/internal/campaigns/messagewall/types.go b/server/backend/internal/campaigns/messagewall/types.go new file mode 100644 index 0000000..3f0989c --- /dev/null +++ b/server/backend/internal/campaigns/messagewall/types.go @@ -0,0 +1,54 @@ +package messagewall + +type Layout struct { + Version int `json:"version"` + CoordinateSpace CoordinateSpace `json:"coordinate_space"` + FitMode string `json:"fit_mode"` + Slots []Slot `json:"slots"` +} + +type CoordinateSpace struct { + Width int `json:"width"` + Height int `json:"height"` + Unit string `json:"unit"` +} + +type Slot struct { + SlotID string `json:"slot_id"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +type ResolveRequest struct { + Layout Layout `json:"layout"` + Source string `json:"source"` + DurationSeconds int `json:"duration_seconds"` + LoadTimeoutSeconds int `json:"load_timeout_seconds"` + OnError string `json:"on_error"` +} + +type ResolveResult struct { + Version int `json:"version"` + CoordinateSpace CoordinateSpace `json:"coordinate_space"` + FitMode string `json:"fit_mode"` + Scenes []ResolvedScene `json:"scenes"` +} + +type ResolvedScene struct { + SlotID string `json:"slot_id"` + Source string `json:"source"` + DurationSeconds int `json:"duration_seconds"` + LoadTimeoutSeconds int `json:"load_timeout_seconds"` + OnError string `json:"on_error"` + Crop Crop `json:"crop"` +} + +type Crop struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Unit string `json:"unit"` +} diff --git a/server/backend/internal/httpapi/errors.go b/server/backend/internal/httpapi/errors.go new file mode 100644 index 0000000..7cf4adf --- /dev/null +++ b/server/backend/internal/httpapi/errors.go @@ -0,0 +1,31 @@ +package httpapi + +import ( + "encoding/json" + "net/http" +) + +type errorEnvelope struct { + Error apiError `json:"error"` +} + +type apiError struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details"` +} + +func writeError(w http.ResponseWriter, status int, code, message string, details any) { + writeJSON(w, status, errorEnvelope{ + Error: apiError{ + Code: code, + Message: message, + Details: details, + }, + }) +} + +func decodeJSON(r *http.Request, dest any) error { + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(dest) +} diff --git a/server/backend/internal/httpapi/messagewall.go b/server/backend/internal/httpapi/messagewall.go new file mode 100644 index 0000000..b4d416c --- /dev/null +++ b/server/backend/internal/httpapi/messagewall.go @@ -0,0 +1,23 @@ +package httpapi + +import ( + "net/http" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/campaigns/messagewall" +) + +func handleResolveMessageWall(w http.ResponseWriter, r *http.Request) { + var request messagewall.ResolveRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON", err.Error()) + return + } + + result, err := messagewall.Resolve(request) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_layout_json", "layout_json is invalid", err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/server/backend/internal/httpapi/messagewall_test.go b/server/backend/internal/httpapi/messagewall_test.go new file mode 100644 index 0000000..2f36d87 --- /dev/null +++ b/server/backend/internal/httpapi/messagewall_test.go @@ -0,0 +1,19 @@ +package httpapi + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandleResolveMessageWallInvalidJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/tools/message-wall/resolve", bytes.NewBufferString("{")) + w := httptest.NewRecorder() + + handleResolveMessageWall(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} diff --git a/server/backend/internal/httpapi/meta.go b/server/backend/internal/httpapi/meta.go new file mode 100644 index 0000000..a147cb1 --- /dev/null +++ b/server/backend/internal/httpapi/meta.go @@ -0,0 +1,21 @@ +package httpapi + +import "net/http" + +func handleMeta(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "service": "morz-infoboard-backend", + "version": "dev", + "api": map[string]any{ + "base_path": "/api/v1", + "health": "/healthz", + "tools": []map[string]string{ + { + "name": "message-wall-resolve", + "method": http.MethodPost, + "path": "/api/v1/tools/message-wall/resolve", + }, + }, + }, + }) +} diff --git a/server/backend/internal/httpapi/response.go b/server/backend/internal/httpapi/response.go new file mode 100644 index 0000000..ba1e020 --- /dev/null +++ b/server/backend/internal/httpapi/response.go @@ -0,0 +1,13 @@ +package httpapi + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index 1b246fb..9945fce 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -1,7 +1,6 @@ package httpapi import ( - "encoding/json" "net/http" ) @@ -16,18 +15,18 @@ func NewRouter() http.Handler { }) mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{ + writeJSON(w, http.StatusOK, map[string]any{ "name": "morz-infoboard-backend", "version": "dev", + "tools": []string{ + "message-wall-resolve", + }, }) }) + mux.HandleFunc("GET /api/v1/meta", handleMeta) + + mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall) + return mux } - -func writeJSON(w http.ResponseWriter, status int, payload any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - - _ = json.NewEncoder(w).Encode(payload) -}