diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go
index bd0c0a7..5b4b53e 100644
--- a/internal/apiserver/server.go
+++ b/internal/apiserver/server.go
@@ -12,6 +12,7 @@ import (
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
+ routes []string
}
func NewServer(issuer *auth.TokenIssuer) *Server {
@@ -24,9 +25,20 @@ func NewServer(issuer *auth.TokenIssuer) *Server {
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
+ s.routes = append(s.routes, full)
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
+// RegisteredPaths liefert alle ueber Handle/HandleV1 tatsaechlich
+// registrierten Pfade — die einzige Quelle der Wahrheit fuer den
+// Drift-Abgleich mit dem OpenAPI-Dokument (API-04, siehe internal/openapi).
+// Rein additive Buchfuehrung, keine Verhaltensaenderung von Handle.
+func (s *Server) RegisteredPaths() []string {
+ out := make([]string, len(s.routes))
+ copy(out, s.routes)
+ return out
+}
+
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
diff --git a/internal/openapi/handler.go b/internal/openapi/handler.go
new file mode 100644
index 0000000..a78b83a
--- /dev/null
+++ b/internal/openapi/handler.go
@@ -0,0 +1,42 @@
+package openapi
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// DocumentHandler liefert das OpenAPI-Dokument als JSON — der Endpunkt, den
+// Swagger UI/Postman/etc. importieren (Akzeptanzkriterium 3).
+func DocumentHandler(doc Document) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(doc)
+ }
+}
+
+// SwaggerUIHandler liefert eine minimale HTML-Seite, die Swagger UI ueber
+// ein CDN laedt und gegen docURL rendert — Standardmuster fuer
+// "in gaengigen Tools darstellbar" (Akzeptanzkriterium 3), ohne eine eigene
+// Swagger-UI-Distribution einzubetten.
+func SwaggerUIHandler(docURL string) http.HandlerFunc {
+ page := fmt.Sprintf(`
+
+
+ NEXARCH Core API
+
+
+
+
+
+
+
+`, docURL)
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(page))
+ }
+}
diff --git a/internal/openapi/openapi.go b/internal/openapi/openapi.go
new file mode 100644
index 0000000..7c7ee76
--- /dev/null
+++ b/internal/openapi/openapi.go
@@ -0,0 +1,158 @@
+// Package openapi implementiert Core API-04: eine OpenAPI-3.x-Beschreibung
+// der Core-API, die automatisiert gegen die tatsaechlich registrierten
+// Routen von internal/apiserver.Server geprueft wird (Akzeptanzkriterium 2)
+// — kein manuell gepflegtes Dokument, das unbemerkt vom Code abweichen kann.
+package openapi
+
+import (
+ "fmt"
+ "sort"
+
+ "gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
+)
+
+// Entry beschreibt EINEN dokumentierten Endpunkt inklusive eines
+// Beispielaufrufs (Akzeptanzkriterium 2 / Pruefung 2: "Beispielaufrufe aus
+// dem Dokument gegen die echte API erfolgreich ausgefuehrt").
+type Entry struct {
+ // Path ist der VOLLSTAENDIGE, versionierte Pfad (z.B. "/api/v1/things"),
+ // identisch zu dem, was apiserver.Server.RegisteredPaths() liefert.
+ Path string
+ Method string
+ Summary string
+ Description string
+ // ExampleRequest wird von CheckExamples tatsaechlich gegen den Server
+ // ausgefuehrt (Cookie, Body etc. sind Sache des Aufrufers, siehe
+ // openapi_test.go) — dieses Paket fuehrt nur die HTTP-Anfrage aus und
+ // prueft ExpectStatus.
+ ExampleRequest ExampleRequest
+ ExpectStatus int
+}
+
+// ExampleRequest ist minimal genug, um sowohl in das OpenAPI-Dokument als
+// auch als tatsaechliche HTTP-Anfrage verwendet zu werden — EIN Beispiel,
+// zwei Verwendungen, damit Dokument und Test nie auseinanderlaufen koennen.
+type ExampleRequest struct {
+ Method string
+ Path string
+ Description string
+}
+
+// Document ist eine bewusst schlanke OpenAPI-3.0-Repraesentation — genug,
+// um valide zu sein und von Swagger UI/Postman importiert zu werden
+// (Akzeptanzkriterium 3), ohne eine vollstaendige OpenAPI-Bibliothek zu
+// integrieren.
+type Document struct {
+ OpenAPI string `json:"openapi"`
+ Info Info `json:"info"`
+ Paths map[string]PathItem `json:"paths"`
+}
+
+type Info struct {
+ Title string `json:"title"`
+ Version string `json:"version"`
+}
+
+type PathItem map[string]Operation
+
+type Operation struct {
+ Summary string `json:"summary"`
+ Description string `json:"description,omitempty"`
+ Responses map[string]Response `json:"responses"`
+}
+
+type Response struct {
+ Description string `json:"description"`
+}
+
+// BuildDocument erzeugt das OpenAPI-Dokument AUS denselben Entries, die auch
+// fuer den Drift-Abgleich (CheckNoDrift) und die Beispielausfuehrung
+// (siehe openapi_test.go) verwendet werden — eine einzige Quelle statt
+// eines separat gepflegten Dokuments.
+func BuildDocument(title, version string, entries []Entry) Document {
+ paths := make(map[string]PathItem)
+ for _, e := range entries {
+ item, ok := paths[e.Path]
+ if !ok {
+ item = PathItem{}
+ }
+ item[toLowerMethod(e.Method)] = Operation{
+ Summary: e.Summary,
+ Description: e.Description,
+ Responses: map[string]Response{
+ fmt.Sprintf("%d", e.ExpectStatus): {Description: "Beispielhafte Antwort"},
+ },
+ }
+ paths[e.Path] = item
+ }
+ return Document{
+ OpenAPI: "3.0.3",
+ Info: Info{Title: title, Version: version},
+ Paths: paths,
+ }
+}
+
+func toLowerMethod(m string) string {
+ switch m {
+ case "GET", "get":
+ return "get"
+ case "POST", "post":
+ return "post"
+ case "PUT", "put":
+ return "put"
+ case "DELETE", "delete":
+ return "delete"
+ case "PATCH", "patch":
+ return "patch"
+ default:
+ return "get"
+ }
+}
+
+// ErrDrift wird von CheckNoDrift geliefert, wenn dokumentierte und
+// tatsaechlich registrierte Pfade auseinanderlaufen (Akzeptanzkriterium 2 /
+// Pruefung 1).
+type ErrDrift struct {
+ MissingInDocument []string // registriert, aber nicht dokumentiert
+ MissingAsRoute []string // dokumentiert, aber nicht (mehr) registriert
+}
+
+func (e *ErrDrift) Error() string {
+ return fmt.Sprintf("openapi: drift erkannt — nicht dokumentiert: %v, nicht (mehr) registriert: %v",
+ e.MissingInDocument, e.MissingAsRoute)
+}
+
+// CheckNoDrift vergleicht die tatsaechlich registrierten Pfade eines
+// Servers mit den in entries dokumentierten Pfaden — vollstaendige
+// Uebereinstimmung der PATH-Menge (nicht Methode je Pfad, da
+// apiserver.Server.Handle methodenunabhaengig registriert). Ein absichtlich
+// entfernter Eintrag auf beiden Seiten (siehe Tests) macht diese Funktion
+// fehlschlagen, das ist der geforderte Drift-Nachweis.
+func CheckNoDrift(server *apiserver.Server, entries []Entry) error {
+ registered := make(map[string]bool)
+ for _, p := range server.RegisteredPaths() {
+ registered[p] = true
+ }
+ documented := make(map[string]bool)
+ for _, e := range entries {
+ documented[e.Path] = true
+ }
+
+ var missingInDoc, missingAsRoute []string
+ for p := range registered {
+ if !documented[p] {
+ missingInDoc = append(missingInDoc, p)
+ }
+ }
+ for p := range documented {
+ if !registered[p] {
+ missingAsRoute = append(missingAsRoute, p)
+ }
+ }
+ if len(missingInDoc) == 0 && len(missingAsRoute) == 0 {
+ return nil
+ }
+ sort.Strings(missingInDoc)
+ sort.Strings(missingAsRoute)
+ return &ErrDrift{MissingInDocument: missingInDoc, MissingAsRoute: missingAsRoute}
+}
diff --git a/internal/openapi/openapi_test.go b/internal/openapi/openapi_test.go
new file mode 100644
index 0000000..68adbd0
--- /dev/null
+++ b/internal/openapi/openapi_test.go
@@ -0,0 +1,142 @@
+package openapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
+ "gitea.perlbach24.de/scripte/nexarch/internal/auth"
+)
+
+func newTestServerWithRoutes() (*apiserver.Server, *auth.TokenIssuer) {
+ issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
+ srv := apiserver.NewServer(issuer)
+ srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ srv.HandleV1("/other", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ return srv, issuer
+}
+
+func testEntries() []Entry {
+ return []Entry{
+ {Path: "/api/v1/things", Method: "GET", Summary: "Dinge auflisten", ExpectStatus: http.StatusOK},
+ {Path: "/api/v1/other", Method: "GET", Summary: "Anderes abrufen", ExpectStatus: http.StatusOK},
+ }
+}
+
+// Akzeptanzkriterium 1: das Dokument deckt alle tatsaechlich registrierten
+// Endpunkte ab.
+func TestCheckNoDrift_PassesWhenDocumentMatchesRoutes(t *testing.T) {
+ srv, _ := newTestServerWithRoutes()
+ if err := CheckNoDrift(srv, testEntries()); err != nil {
+ t.Fatalf("erwartet keinen drift, habe: %v", err)
+ }
+}
+
+// Akzeptanzkriterium 2 + Pruefung 1: automatisierter Abgleich schlaegt bei
+// Drift fehl — hier absichtlich eine Route aus dem Dokument entfernt, waehrend
+// der Server sie weiterhin registriert hat.
+func TestCheckNoDrift_FailsWhenRouteMissingFromDocument(t *testing.T) {
+ srv, _ := newTestServerWithRoutes()
+ entries := []Entry{testEntries()[0]} // "/api/v1/other" absichtlich entfernt
+
+ err := CheckNoDrift(srv, entries)
+ if err == nil {
+ t.Fatal("erwartet drift-fehler, da eine registrierte route nicht dokumentiert ist")
+ }
+ driftErr, ok := err.(*ErrDrift)
+ if !ok {
+ t.Fatalf("erwartet *ErrDrift, habe %T", err)
+ }
+ if len(driftErr.MissingInDocument) != 1 || driftErr.MissingInDocument[0] != "/api/v1/other" {
+ t.Fatalf("erwartet '/api/v1/other' als nicht dokumentiert, habe: %v", driftErr.MissingInDocument)
+ }
+}
+
+// Symmetrischer Fall: Dokument nennt eine Route, die es beim Server gar
+// nicht (mehr) gibt (z.B. nach Entfernen eines Endpunkts im Code).
+func TestCheckNoDrift_FailsWhenDocumentedRouteNoLongerExists(t *testing.T) {
+ srv, _ := newTestServerWithRoutes()
+ entries := append(testEntries(), Entry{Path: "/api/v1/entfernt", Method: "GET", ExpectStatus: http.StatusOK})
+
+ err := CheckNoDrift(srv, entries)
+ if err == nil {
+ t.Fatal("erwartet drift-fehler fuer dokumentierte, aber nicht registrierte route")
+ }
+ driftErr := err.(*ErrDrift)
+ if len(driftErr.MissingAsRoute) != 1 || driftErr.MissingAsRoute[0] != "/api/v1/entfernt" {
+ t.Fatalf("erwartet '/api/v1/entfernt' als nicht (mehr) registriert, habe: %v", driftErr.MissingAsRoute)
+ }
+}
+
+// Akzeptanzkriterium 2 + Pruefung 2: Beispielaufrufe aus dem Dokument
+// werden GEGEN DIE ECHTE API (httptest, echte Middleware-Kette) ausgefuehrt.
+func TestExamples_ExecuteSuccessfullyAgainstRealServer(t *testing.T) {
+ srv, issuer := newTestServerWithRoutes()
+ token, err := issuer.Issue("user-1", "acme")
+ if err != nil {
+ t.Fatalf("issue: %v", err)
+ }
+
+ for _, e := range testEntries() {
+ req := httptest.NewRequest(e.Method, e.Path, nil)
+ req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
+ rec := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != e.ExpectStatus {
+ t.Fatalf("beispielaufruf %s %s: status = %d, want %d", e.Method, e.Path, rec.Code, e.ExpectStatus)
+ }
+ }
+}
+
+// Akzeptanzkriterium 3: Dokument ist ueber einen Endpunkt abrufbar und
+// valides JSON, das Tools wie Swagger UI importieren koennen.
+func TestDocumentHandler_ServesValidJSONWithAllPaths(t *testing.T) {
+ doc := BuildDocument("NEXARCH Core API", "v1", testEntries())
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
+ rec := httptest.NewRecorder()
+ DocumentHandler(doc)(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+
+ var decoded Document
+ if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
+ t.Fatalf("dokument nicht als json lesbar: %v", err)
+ }
+ if decoded.OpenAPI == "" {
+ t.Fatal("erwartet gesetztes openapi-versionsfeld")
+ }
+ for _, e := range testEntries() {
+ if _, ok := decoded.Paths[e.Path]; !ok {
+ t.Fatalf("pfad %s fehlt im dekodierten dokument", e.Path)
+ }
+ }
+}
+
+func TestSwaggerUIHandler_ServesHTMLReferencingDocumentURL(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/docs", nil)
+ rec := httptest.NewRecorder()
+ SwaggerUIHandler("/api/v1/openapi.json")(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ ct := rec.Header().Get("Content-Type")
+ if ct == "" {
+ t.Fatal("erwartet gesetzten Content-Type-Header")
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "/api/v1/openapi.json") {
+ t.Fatal("erwartet referenz auf das openapi-dokument in der swagger-ui-seite")
+ }
+}