Baue Layout-Resolver und lokale Entwicklungsgerueste aus
This commit is contained in:
parent
6a65505304
commit
bf993a5945
30 changed files with 865 additions and 25 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -61,6 +61,11 @@ Spaeter zusaetzlich sinnvoll:
|
||||||
- `postgresql-client`
|
- `postgresql-client`
|
||||||
- `mosquitto-clients`
|
- `mosquitto-clients`
|
||||||
|
|
||||||
|
Fuer Container-Builds liegen erste Dockerfiles in:
|
||||||
|
|
||||||
|
- `server/backend/Dockerfile`
|
||||||
|
- `player/agent/Dockerfile`
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
Repository klonen:
|
Repository klonen:
|
||||||
|
|
@ -77,6 +82,7 @@ Dokumentationsbasis lesen:
|
||||||
3. `TECH-STACK.md`
|
3. `TECH-STACK.md`
|
||||||
4. `docs/SCHEMA.md`
|
4. `docs/SCHEMA.md`
|
||||||
5. `docs/OFFENE-ARCHITEKTURFRAGEN.md`
|
5. `docs/OFFENE-ARCHITEKTURFRAGEN.md`
|
||||||
|
6. `docs/LAYOUT-JSON.md`
|
||||||
|
|
||||||
## Build-Kommandos
|
## Build-Kommandos
|
||||||
|
|
||||||
|
|
@ -100,6 +106,19 @@ go build ./...
|
||||||
make 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
|
## Lokaler Start
|
||||||
|
|
||||||
### Backend lokal starten
|
### Backend lokal starten
|
||||||
|
|
@ -138,6 +157,12 @@ Standardwerte:
|
||||||
- `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080`
|
- `MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080`
|
||||||
- `MORZ_INFOBOARD_MQTT_BROKER=tcp://127.0.0.1:1883`
|
- `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:
|
Beispiel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -163,6 +188,12 @@ go run ./cmd/agent
|
||||||
4. Agent: strukturierte Logs und Health-Modell einziehen
|
4. Agent: strukturierte Logs und Health-Modell einziehen
|
||||||
5. Danach erst DB-, MQTT- und API-Funktionalitaet ausbauen
|
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
|
## Arbeitsweise
|
||||||
|
|
||||||
Empfohlen:
|
Empfohlen:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Die Trennung von `/srv/docker/infoboard-netboot` ist sinnvoll, damit:
|
||||||
- Technologieentscheidungen: `TECH-STACK.md`
|
- Technologieentscheidungen: `TECH-STACK.md`
|
||||||
- Entwicklungsleitfaden: `DEVELOPMENT.md`
|
- Entwicklungsleitfaden: `DEVELOPMENT.md`
|
||||||
- Template-/Kampagnenkonzept: `docs/TEMPLATE-KONZEPT.md`
|
- Template-/Kampagnenkonzept: `docs/TEMPLATE-KONZEPT.md`
|
||||||
|
- `layout_json` fuer `message_wall`: `docs/LAYOUT-JSON.md`
|
||||||
- Provisionierungskonzept: `docs/PROVISIONIERUNGSKONZEPT.md`
|
- Provisionierungskonzept: `docs/PROVISIONIERUNGSKONZEPT.md`
|
||||||
- Player-Konzept: `docs/PLAYER-KONZEPT.md`
|
- Player-Konzept: `docs/PLAYER-KONZEPT.md`
|
||||||
- Server-Konzept: `docs/SERVER-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
|
- `compose/` fuer Container-Definitionen und Stack-Bausteine
|
||||||
- `scripts/` fuer Hilfsskripte
|
- `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
|
## Naechste sinnvolle Inhalte in der Struktur
|
||||||
|
|
||||||
- `docs/` fuer weitere technische Detaildokumente
|
- `docs/` fuer weitere technische Detaildokumente
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,8 @@ Naechster geplanter Ausbau:
|
||||||
- Rolle `signage_provision`
|
- Rolle `signage_provision`
|
||||||
- Rolle `signage_player`
|
- Rolle `signage_player`
|
||||||
- Beispiel-Inventories fuer Wand- und Einzelanzeigen
|
- Beispiel-Inventories fuer Wand- und Einzelanzeigen
|
||||||
|
|
||||||
|
Aktuell vorhanden:
|
||||||
|
|
||||||
|
- `inventory.example.yml`
|
||||||
|
- `site.yml` als Platzhalter-Playbook
|
||||||
|
|
|
||||||
16
ansible/inventory.example.yml
Normal file
16
ansible/inventory.example.yml
Normal file
|
|
@ -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
|
||||||
7
ansible/site.yml
Normal file
7
ansible/site.yml
Normal file
|
|
@ -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 }}"
|
||||||
12
compose/README.md
Normal file
12
compose/README.md
Normal file
|
|
@ -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
|
||||||
2
compose/mosquitto.conf
Normal file
2
compose/mosquitto.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
21
compose/server-stack.yml
Normal file
21
compose/server-stack.yml
Normal file
|
|
@ -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:
|
||||||
213
docs/LAYOUT-JSON.md
Normal file
213
docs/LAYOUT-JSON.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -288,3 +288,5 @@ Vor der UI- und Render-Implementierung gilt:
|
||||||
- `message_wall` wird serverseitig in konkrete Zielinhalte aufgeloest
|
- `message_wall` wird serverseitig in konkrete Zielinhalte aufgeloest
|
||||||
- `layout_json` beschreibt die serverseitige Segmentlogik und Admin-Vorschau
|
- `layout_json` beschreibt die serverseitige Segmentlogik und Admin-Vorschau
|
||||||
- Slots werden geometrisch serverseitig interpretiert, nicht im Player berechnet
|
- Slots werden geometrisch serverseitig interpretiert, nicht im Player berechnet
|
||||||
|
|
||||||
|
Details dazu stehen in `docs/LAYOUT-JSON.md`.
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,4 @@ Geplant:
|
||||||
Aktuell vorhanden:
|
Aktuell vorhanden:
|
||||||
|
|
||||||
- `agent/` mit erstem Go-Geruest
|
- `agent/` mit erstem Go-Geruest
|
||||||
|
- `config/config.example.json` als lokale Beispielkonfiguration
|
||||||
|
|
|
||||||
10
player/agent/Dockerfile
Normal file
10
player/agent/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -14,3 +14,8 @@ Geplante Unterstruktur:
|
||||||
- `cmd/agent/` fuer den Startpunkt
|
- `cmd/agent/` fuer den Startpunkt
|
||||||
- `internal/app/` fuer Initialisierung und Laufzeit
|
- `internal/app/` fuer Initialisierung und Laufzeit
|
||||||
- `internal/config/` fuer Konfiguration
|
- `internal/config/` fuer Konfiguration
|
||||||
|
|
||||||
|
Aktuell vorhanden:
|
||||||
|
|
||||||
|
- Env-basierte und dateibasierte Konfiguration
|
||||||
|
- einfacher Laufzeit-Loop mit Heartbeat-Ticks im Log
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/player/agent/internal/config"
|
"git.az-it.net/az/morz-infoboard/player/agent/internal/config"
|
||||||
|
|
@ -9,20 +11,32 @@ import (
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
|
logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() (*App, error) {
|
func New() (*App, error) {
|
||||||
cfg := config.Load()
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
if cfg.ScreenID == "" {
|
return nil, err
|
||||||
return nil, fmt.Errorf("screen id is required")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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 {
|
for {
|
||||||
time.Sleep(30 * time.Second)
|
a.logger.Printf("heartbeat tick screen=%s", a.Config.ScreenID)
|
||||||
|
<-ticker.C
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,66 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ScreenID string
|
ScreenID string `json:"screen_id"`
|
||||||
ServerBaseURL string
|
ServerBaseURL string `json:"server_base_url"`
|
||||||
MQTTBroker string
|
MQTTBroker string `json:"mqtt_broker"`
|
||||||
|
HeartbeatEvery int `json:"heartbeat_every_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
const defaultConfigPath = "/etc/signage/config.json"
|
||||||
return Config{
|
|
||||||
ScreenID: getenv("MORZ_INFOBOARD_SCREEN_ID", "unset-screen"),
|
func Load() (Config, error) {
|
||||||
ServerBaseURL: getenv("MORZ_INFOBOARD_SERVER_URL", "http://127.0.0.1:8080"),
|
cfg := defaultConfig()
|
||||||
MQTTBroker: getenv("MORZ_INFOBOARD_MQTT_BROKER", "tcp://127.0.0.1:1883"),
|
|
||||||
|
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 {
|
func getenv(key, fallback string) string {
|
||||||
|
|
|
||||||
42
player/agent/internal/config/config_test.go
Normal file
42
player/agent/internal/config/config_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
player/config/config.example.json
Normal file
6
player/config/config.example.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
11
server/backend/Dockerfile
Normal file
11
server/backend/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -7,10 +7,20 @@ Ziel fuer die erste Ausbaustufe:
|
||||||
- HTTP-API in Go
|
- HTTP-API in Go
|
||||||
- Health-Endpunkt
|
- Health-Endpunkt
|
||||||
- saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule
|
- saubere Projektstruktur fuer spaetere API-, Worker- und Datenbankmodule
|
||||||
|
- erste serverseitige Aufloesungslogik fuer `message_wall`
|
||||||
|
|
||||||
Geplante Unterstruktur:
|
Geplante Unterstruktur:
|
||||||
|
|
||||||
- `cmd/api/` fuer den API-Startpunkt
|
- `cmd/api/` fuer den API-Startpunkt
|
||||||
- `internal/app/` fuer App-Initialisierung
|
- `internal/app/` fuer App-Initialisierung
|
||||||
|
- `internal/campaigns/` fuer Kampagnen- und Template-Logik
|
||||||
- `internal/httpapi/` fuer HTTP-Routing und Handler
|
- `internal/httpapi/` fuer HTTP-Routing und Handler
|
||||||
- `internal/config/` fuer Konfiguration
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||||
|
|
@ -25,5 +26,10 @@ func New() (*App, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
return a.server.ListenAndServe()
|
err := a.server.ListenAndServe()
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
server/backend/internal/campaigns/messagewall/resolver.go
Normal file
131
server/backend/internal/campaigns/messagewall/resolver.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
server/backend/internal/campaigns/messagewall/types.go
Normal file
54
server/backend/internal/campaigns/messagewall/types.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
31
server/backend/internal/httpapi/errors.go
Normal file
31
server/backend/internal/httpapi/errors.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
23
server/backend/internal/httpapi/messagewall.go
Normal file
23
server/backend/internal/httpapi/messagewall.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
19
server/backend/internal/httpapi/messagewall_test.go
Normal file
19
server/backend/internal/httpapi/messagewall_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
server/backend/internal/httpapi/meta.go
Normal file
21
server/backend/internal/httpapi/meta.go
Normal file
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
server/backend/internal/httpapi/response.go
Normal file
13
server/backend/internal/httpapi/response.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,18 +15,18 @@ func NewRouter() http.Handler {
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, r *http.Request) {
|
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",
|
"name": "morz-infoboard-backend",
|
||||||
"version": "dev",
|
"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
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue