diff options
| author | Chia <Chia@93.nz> | 2026-08-04 19:58:52 +1200 |
|---|---|---|
| committer | Chia <Chia@93.nz> | 2026-08-04 20:43:23 +1200 |
| commit | 5b651488b081b65fda8a323f228e139adb79a35d (patch) | |
| tree | 08baf40efb8fe103b32721cd991ff712323e3173 /internal/adminapi | |
Build AI gateway control plane and admin UI
Diffstat (limited to '')
| -rw-r--r-- | internal/adminapi/api.go | 352 |
1 files changed, 352 insertions, 0 deletions
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go new file mode 100644 index 0000000..1eb2a5d --- /dev/null +++ b/internal/adminapi/api.go @@ -0,0 +1,352 @@ +package adminapi + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "aigw/internal/adminui" + "aigw/internal/apierror" + "aigw/internal/controlplane" + + "github.com/jackc/pgx/v5/pgconn" +) + +type API struct { + store *controlplane.Store + manager *controlplane.Manager + token []byte + logger *slog.Logger + prefix string +} + +type Options struct { + Store *controlplane.Store + Manager *controlplane.Manager + Token string + Logger *slog.Logger + Prefix string +} + +func New(options Options) *API { + prefix := strings.TrimRight(options.Prefix, "/") + if prefix == "" { + prefix = "/admin" + } + return &API{store: options.Store, manager: options.Manager, token: []byte(options.Token), logger: options.Logger, prefix: prefix} +} + +func (a *API) Handler() http.Handler { + mux := http.NewServeMux() + apiPrefix := a.prefix + "/api" + mux.HandleFunc("GET "+a.prefix, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, a.prefix+"/", http.StatusTemporaryRedirect) + }) + mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler())) + + mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth(a.overview)) + mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth(a.listTenants)) + mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth(a.createTenant)) + mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth(a.listProjects)) + mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth(a.createProject)) + mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth(a.listKeys)) + mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth(a.createKey)) + mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth(a.revokeKey)) + mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth(a.listProviders)) + mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth(a.createProvider)) + mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth(a.toggleProvider)) + mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth(a.listModels)) + mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth(a.createModel)) + mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth(a.toggleModel)) + mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth(a.reload)) + return mux +} + +func (a *API) withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) + if provided == "" { + provided = bearerToken(r.Header.Get("Authorization")) + } + if len(provided) == 0 || subtle.ConstantTimeCompare([]byte(provided), a.token) != 1 { + apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "admin_unauthorized", Message: "Administrator authentication required"}, requestID(r)) + return + } + next(w, r) + } +} + +func (a *API) overview(w http.ResponseWriter, r *http.Request) { + result, err := a.store.Overview(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + result.RuntimeGeneration = a.manager.Generation() + result.RedisConfigured = a.manager.RedisConfigured() + result.RedisConnected = a.manager.RedisConnected() + writeJSON(w, result) +} + +func (a *API) listTenants(w http.ResponseWriter, r *http.Request) { + result, err := a.store.ListTenants(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) createTenant(w http.ResponseWriter, r *http.Request) { + var input controlplane.CreateTenantInput + if !decodeBody(w, r, &input) { + return + } + result, generation, err := a.store.CreateTenant(r.Context(), input) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "tenant", result.ID) { + return + } + writeStatusJSON(w, http.StatusCreated, result) +} + +func (a *API) listProjects(w http.ResponseWriter, r *http.Request) { + result, err := a.store.ListProjects(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) createProject(w http.ResponseWriter, r *http.Request) { + var input controlplane.CreateProjectInput + if !decodeBody(w, r, &input) { + return + } + result, generation, err := a.store.CreateProject(r.Context(), input) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "project", result.ID) { + return + } + writeStatusJSON(w, http.StatusCreated, result) +} + +func (a *API) listKeys(w http.ResponseWriter, r *http.Request) { + result, err := a.store.ListAPIKeys(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) createKey(w http.ResponseWriter, r *http.Request) { + var input controlplane.CreateAPIKeyInput + if !decodeBody(w, r, &input) { + return + } + result, generation, err := a.store.CreateAPIKey(r.Context(), input) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "api_key", result.ID) { + return + } + writeStatusJSON(w, http.StatusCreated, result) +} + +func (a *API) revokeKey(w http.ResponseWriter, r *http.Request) { + generation, err := a.store.RevokeAPIKey(r.Context(), r.PathValue("id")) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "api_key", r.PathValue("id")) { + return + } + writeJSON(w, map[string]any{"status": "revoked"}) +} + +func (a *API) listProviders(w http.ResponseWriter, r *http.Request) { + result, err := a.store.ListProviders(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) createProvider(w http.ResponseWriter, r *http.Request) { + var input controlplane.CreateProviderInput + if !decodeBody(w, r, &input) { + return + } + result, generation, err := a.store.CreateProvider(r.Context(), input) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "provider", result.ID) { + return + } + writeStatusJSON(w, http.StatusCreated, result) +} + +func (a *API) toggleProvider(w http.ResponseWriter, r *http.Request) { + var input struct { + Enabled bool `json:"enabled"` + } + if !decodeBody(w, r, &input) { + return + } + id := r.PathValue("id") + generation, err := a.store.SetProviderEnabled(r.Context(), id, input.Enabled) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "provider", id) { + return + } + writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled}) +} + +func (a *API) listModels(w http.ResponseWriter, r *http.Request) { + result, err := a.store.ListModels(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, result) +} + +func (a *API) createModel(w http.ResponseWriter, r *http.Request) { + var input controlplane.CreateModelInput + if !decodeBody(w, r, &input) { + return + } + result, generation, err := a.store.CreateModel(r.Context(), input) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "model", result.ID) { + return + } + writeStatusJSON(w, http.StatusCreated, result) +} + +func (a *API) toggleModel(w http.ResponseWriter, r *http.Request) { + var input struct { + Enabled bool `json:"enabled"` + } + if !decodeBody(w, r, &input) { + return + } + id := r.PathValue("id") + generation, err := a.store.SetModelEnabled(r.Context(), id, input.Enabled) + if err != nil { + a.mutationError(w, r, err) + return + } + if !a.changed(w, r, generation, "model", id) { + return + } + writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled}) +} + +func (a *API) reload(w http.ResponseWriter, r *http.Request) { + generation, err := a.manager.Reload(r.Context()) + if err != nil { + a.databaseError(w, r, err) + return + } + writeJSON(w, map[string]any{"generation": generation, "status": "reloaded"}) +} + +func (a *API) changed(w http.ResponseWriter, r *http.Request, generation int64, resource, id string) bool { + if err := a.manager.AfterMutation(r.Context(), generation, resource, id); err != nil { + a.logger.Error("admin_control_plane_sync_failed", "resource", resource, "id", id, "error", err) + apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_sync_failed", Message: "Change was persisted but runtime reload failed; retry reload"}, requestID(r)) + return false + } + return true +} + +func (a *API) databaseError(w http.ResponseWriter, r *http.Request, err error) { + a.logger.Error("admin_database_error", "error", err) + apierror.Write(w, apierror.Error{Status: http.StatusInternalServerError, Type: "control_plane_error", Message: "Control plane unavailable"}, requestID(r)) +} + +func (a *API) mutationError(w http.ResponseWriter, r *http.Request, err error) { + status := http.StatusBadRequest + typeName := "invalid_params" + message := err.Error() + if errors.Is(err, controlplane.ErrNotFound) { + status = http.StatusNotFound + typeName = "not_found" + message = "Resource not found" + } + var pgError *pgconn.PgError + if errors.As(err, &pgError) { + switch pgError.Code { + case "23505": + status = http.StatusConflict + typeName = "already_exists" + message = "Resource already exists" + case "23503": + status = http.StatusBadRequest + typeName = "invalid_reference" + message = "Referenced resource does not exist" + } + } + apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r)) +} + +func decodeBody(w http.ResponseWriter, r *http.Request, destination any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + defer r.Body.Close() + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_params", Message: "Invalid JSON request body"}, requestID(r)) + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, value any) { + writeStatusJSON(w, http.StatusOK, value) +} + +func writeStatusJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func bearerToken(header string) string { + parts := strings.Fields(header) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return parts[1] + } + return "" +} + +func requestID(r *http.Request) string { + if value := r.Header.Get("X-AIGW-Request-ID"); value != "" { + return value + } + return "admin" +} |
