summaryrefslogtreecommitdiff
path: root/internal/adminapi/api.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/adminapi/api.go')
-rw-r--r--internal/adminapi/api.go352
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"
+}