INT-09: Nachtrag - AI-seitige GOV-01-HTTP-Schnittstelle (ai/internal/moduletypeapi) aus INT-12 uebernommen
INT-12 (DMS-Variante dieser Integration) ist auf main noch nicht gemerged, nur auf dem eigenen Feature-Branch vorhanden. Ohne den HTTP-Endpunkt bei AI gibt es fuer den Mail-seitigen Client aus INT-09 kein Ziel. Unveraendert aus dem INT-12-Branch uebernommen (Service-Token-Pflicht, Tenant-Verifikation gegen Core TEN-01/GOV-07 ueber tenantregistryclient, Route nur verdrahtet bei konfigurierter Core-Basis-URL). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dac7fff504
commit
45541d1577
3 files changed
+236
-10
No files matched your search
+28
-10
@@ -27,6 +27,8 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletypeapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
|
||||
@@ -39,6 +41,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantregistryclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
|
||||
)
|
||||
@@ -49,11 +52,13 @@ const defaultListenAddr = "127.0.0.1:9600"
|
||||
const defaultModel = "default"
|
||||
|
||||
type config struct {
|
||||
dsn string
|
||||
serviceToken string
|
||||
addr string
|
||||
model string
|
||||
moduleURLs map[string]string
|
||||
dsn string
|
||||
serviceToken string
|
||||
addr string
|
||||
model string
|
||||
moduleURLs map[string]string
|
||||
coreBaseURL string
|
||||
coreServiceToken string
|
||||
}
|
||||
|
||||
// parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine
|
||||
@@ -79,11 +84,13 @@ func parseModuleBaseURLs(raw string) map[string]string {
|
||||
// Programmabbruchs weiter unten im Code.
|
||||
func loadConfig(getenv func(string) string) (config, error) {
|
||||
cfg := config{
|
||||
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
|
||||
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
|
||||
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
|
||||
model: getenv("NEXARCH_AI_APP_MODEL"),
|
||||
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
|
||||
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
|
||||
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
|
||||
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
|
||||
model: getenv("NEXARCH_AI_APP_MODEL"),
|
||||
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
|
||||
coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"),
|
||||
coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"),
|
||||
}
|
||||
if cfg.dsn == "" {
|
||||
return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich")
|
||||
@@ -150,6 +157,17 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
|
||||
cfg.serviceToken,
|
||||
).RegisterRoutes(mux)
|
||||
|
||||
// GOV-01/INT-12: Objekttyp-Registrierung durch DMS/Mail nur zulaessig,
|
||||
// solange Core TEN-01 fuer die Tenant-Verifikation erreichbar konfiguriert
|
||||
// ist (GOV-07 fail-closed) - ohne Core-Basis-URL bleibt der Endpunkt
|
||||
// unverdrahtet statt eine Registrierung ohne Verifikation zu erlauben.
|
||||
if cfg.coreBaseURL != "" {
|
||||
verifier := tenantregistryclient.New(cfg.coreBaseURL, cfg.coreServiceToken)
|
||||
moduletypeapi.NewServer(&moduletype.Registry{Pool: pool, Verifier: verifier}, cfg.serviceToken).RegisterRoutes(mux)
|
||||
} else {
|
||||
log.Print("ai-app: NEXARCH_AI_APP_CORE_BASE_URL nicht gesetzt, Objekttyp-Registrierung (GOV-01) deaktiviert")
|
||||
}
|
||||
|
||||
quotaStore := quota.NewStore(pool, nil)
|
||||
q := queue.NewQueue(pool, 5*time.Minute)
|
||||
q.SetQuotaEnforcer(quotaStore)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package moduletypeapi ist die HTTP-Bindung fuer moduletype (GOV-01):
|
||||
// DMS/Mail sind eigene Go-Module und koennen moduletype.Registry nicht
|
||||
// direkt importieren, nur ueber diese Schnittstelle (Muster wie
|
||||
// extractapi/residencyapi), Service-Token-Pflicht, fail closed.
|
||||
package moduletypeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
registry *moduletype.Registry
|
||||
serviceToken string
|
||||
}
|
||||
|
||||
func NewServer(registry *moduletype.Registry, serviceToken string) *Server {
|
||||
return &Server{registry: registry, serviceToken: serviceToken}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /module-objecttypes", s.requireAuth(s.handleRegister))
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
got := r.Header.Get("X-Service-Token")
|
||||
if !svcauth.Equal(got, s.serviceToken) {
|
||||
writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type fieldDTO struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ModuleName string `json:"module_name"`
|
||||
ObjectType string `json:"object_type"`
|
||||
Fields []fieldDTO `json:"fields"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
|
||||
fields := make([]moduletype.Field, 0, len(req.Fields))
|
||||
for _, f := range req.Fields {
|
||||
fields = append(fields, moduletype.Field{Name: f.Name, Kind: f.Kind})
|
||||
}
|
||||
|
||||
err := s.registry.Register(r.Context(), req.TenantSlug, req.ModuleName, req.ObjectType, fields)
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
case errors.Is(err, moduletype.ErrUnknownTenant):
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package moduletypeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
type fakeVerifier struct{ validTenants map[string]bool }
|
||||
|
||||
func (f fakeVerifier) VerifyTenant(_ context.Context, tenantSlug string) (bool, error) {
|
||||
return f.validTenants[tenantSlug], nil
|
||||
}
|
||||
|
||||
func newServer(t *testing.T, serviceToken string) *Server {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
migrationsDir, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis: %v", err)
|
||||
}
|
||||
migrations, err := migrate.Load(migrationsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
registry := &moduletype.Registry{Pool: pool, Verifier: fakeVerifier{validTenants: map[string]bool{"mandant-int12": true}}}
|
||||
return NewServer(registry, serviceToken)
|
||||
}
|
||||
|
||||
// TestHandleRegister_ValidRequest_Registered deckt AK1 ab: ein Objekttyp
|
||||
// samt Feldern wird ueber den HTTP-Endpunkt registriert und erscheint in der
|
||||
// Registry.
|
||||
func TestHandleRegister_ValidRequest_Registered(t *testing.T) {
|
||||
s := newServer(t, "test-token")
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(registerRequest{
|
||||
TenantSlug: "mandant-int12",
|
||||
ModuleName: "dms",
|
||||
ObjectType: "document",
|
||||
Fields: []fieldDTO{
|
||||
{Name: "fulltext", Kind: "text"},
|
||||
{Name: "title", Kind: "text"},
|
||||
},
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/module-objecttypes", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "test-token")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
got, err := s.registry.Get(context.Background(), "mandant-int12", "dms", "document")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if len(got.Fields) != 2 || got.Fields[0].Name != "fulltext" {
|
||||
t.Fatalf("unerwartete felder in der registry: %+v", got.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRegister_MissingServiceToken_Rejected deckt die Auth-Pflicht ab:
|
||||
// ohne gueltiges Service-Token kein Zugriff, fail closed.
|
||||
func TestHandleRegister_MissingServiceToken_Rejected(t *testing.T) {
|
||||
s := newServer(t, "test-token")
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(registerRequest{TenantSlug: "mandant-int12", ModuleName: "dms", ObjectType: "document", Fields: []fieldDTO{{Name: "title", Kind: "text"}}})
|
||||
resp, err := http.Post(srv.URL+"/module-objecttypes", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRegister_UnknownTenant_Rejected deckt GOV-07 ab: ein Tenant, den
|
||||
// Core TEN-01 nicht fuehrt, wird mit 403 abgewiesen statt uebernommen.
|
||||
func TestHandleRegister_UnknownTenant_Rejected(t *testing.T) {
|
||||
s := newServer(t, "test-token")
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
body, _ := json.Marshal(registerRequest{TenantSlug: "fremder-mandant", ModuleName: "dms", ObjectType: "document", Fields: []fieldDTO{{Name: "title", Kind: "text"}}})
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/module-objecttypes", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "test-token")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user