diff --git a/ai-services/cmd/ai-services/cmd/catalog/apiserver.go b/ai-services/cmd/ai-services/cmd/catalog/apiserver.go index a477e2210..f625ccf06 100644 --- a/ai-services/cmd/ai-services/cmd/catalog/apiserver.go +++ b/ai-services/cmd/ai-services/cmd/catalog/apiserver.go @@ -91,13 +91,14 @@ func runAPIServer(port int, accessTTL, refreshTTL time.Duration, adminUser, admi // Initialize application repository and service applicationRepo := repository.NewApplicationRepository(pool) - serviceDependencyRepo := repository.NewServiceDependencyRepository(pool) + serviceRepo := repository.NewServiceRepository(pool) + depRepo := repository.NewServiceDependencyRepository(pool) componentRepo := repository.NewComponentRepository(pool) catalogProvider, err := catalog.NewCatalogProvider() if err != nil { return fmt.Errorf("failed to initialize catalog provider: %w", err) } - applicationService := apirepository.NewApplicationService(applicationRepo, serviceDependencyRepo, componentRepo, catalogProvider) + applicationService := apirepository.NewApplicationService(applicationRepo, serviceRepo, depRepo, componentRepo, catalogProvider) tokenMgr := auth.NewTokenManager(secretKey, accessTTL, refreshTTL) authSvc := auth.NewAuthService(userRepo, tokenMgr, blacklist) diff --git a/ai-services/docs/docs.go b/ai-services/docs/docs.go index 92934c215..8a880528e 100644 --- a/ai-services/docs/docs.go +++ b/ai-services/docs/docs.go @@ -169,14 +169,14 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Retrieves a single application by its unique identifier for the authenticated user", + "description": "Get detailed information about a specific application", "produces": [ "application/json" ], "tags": [ "Applications" ], - "summary": "Get application by ID", + "summary": "Get application details", "parameters": [ { "type": "string", @@ -188,27 +188,17 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application" - } - }, - "401": { - "description": "Unauthorized", + "description": "Application details", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } }, "404": { "description": "Application not found", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } } } @@ -219,7 +209,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Updates the display name of an existing application", + "description": "Update an existing application's configuration", "consumes": [ "application/json" ], @@ -233,56 +223,25 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Application ID (UUID)", + "description": "Application ID", "name": "id", "in": "path", "required": true - }, - { - "description": "Update request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.UpdateApplicationRequest" - } } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application" - } - }, - "400": { - "description": "Invalid request body or name validation failed", + "description": "Application updated", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "403": { - "description": "User doesn't own this application", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } }, "404": { "description": "Application not found", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } } } @@ -293,7 +252,10 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Delete a specific application and all its resources", + "description": "Initiates async deletion of an application and all its associated resources", + "produces": [ + "application/json" + ], "tags": [ "Applications" ], @@ -301,25 +263,59 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Application ID", + "description": "Application UUID", "name": "id", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "Skip infrastructure cleanup", + "name": "skip-cleanup", + "in": "query" } ], "responses": { - "200": { - "description": "Application deleted", + "202": { + "description": "Accepted", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_apiserver_repository.DeleteApplicationResponse" + } + }, + "400": { + "description": "Invalid application ID", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" } }, "404": { "description": "Application not found", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "409": { + "description": "Application already being deleted", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" } } } @@ -988,6 +984,20 @@ const docTemplate = `{ } } }, + "github_com_project-ai-services_ai-services_internal_pkg_catalog_apiserver_repository.DeleteApplicationResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application": { "type": "object", "properties": { @@ -1417,19 +1427,6 @@ const docTemplate = `{ } } }, - "internal_pkg_catalog_apiserver_handlers.UpdateApplicationRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "maxLength": 100, - "minLength": 3 - } - } - }, "internal_pkg_catalog_apiserver_handlers.loginReq": { "type": "object", "required": [ diff --git a/ai-services/docs/swagger.json b/ai-services/docs/swagger.json index d381bcd08..47f196f75 100644 --- a/ai-services/docs/swagger.json +++ b/ai-services/docs/swagger.json @@ -163,14 +163,14 @@ "BearerAuth": [] } ], - "description": "Retrieves a single application by its unique identifier for the authenticated user", + "description": "Get detailed information about a specific application", "produces": [ "application/json" ], "tags": [ "Applications" ], - "summary": "Get application by ID", + "summary": "Get application details", "parameters": [ { "type": "string", @@ -182,27 +182,17 @@ ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application" - } - }, - "401": { - "description": "Unauthorized", + "description": "Application details", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } }, "404": { "description": "Application not found", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } } } @@ -213,7 +203,7 @@ "BearerAuth": [] } ], - "description": "Updates the display name of an existing application", + "description": "Update an existing application's configuration", "consumes": [ "application/json" ], @@ -227,56 +217,25 @@ "parameters": [ { "type": "string", - "description": "Application ID (UUID)", + "description": "Application ID", "name": "id", "in": "path", "required": true - }, - { - "description": "Update request", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.UpdateApplicationRequest" - } } ], "responses": { "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application" - } - }, - "400": { - "description": "Invalid request body or name validation failed", + "description": "Application updated", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "403": { - "description": "User doesn't own this application", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } }, "404": { "description": "Application not found", "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + "type": "object", + "additionalProperties": true } } } @@ -287,7 +246,10 @@ "BearerAuth": [] } ], - "description": "Delete a specific application and all its resources", + "description": "Initiates async deletion of an application and all its associated resources", + "produces": [ + "application/json" + ], "tags": [ "Applications" ], @@ -295,25 +257,59 @@ "parameters": [ { "type": "string", - "description": "Application ID", + "description": "Application UUID", "name": "id", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "Skip infrastructure cleanup", + "name": "skip-cleanup", + "in": "query" } ], "responses": { - "200": { - "description": "Application deleted", + "202": { + "description": "Accepted", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_apiserver_repository.DeleteApplicationResponse" + } + }, + "400": { + "description": "Invalid application ID", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" } }, "404": { "description": "Application not found", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "409": { + "description": "Application already being deleted", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse" } } } @@ -982,6 +978,20 @@ } } }, + "github_com_project-ai-services_ai-services_internal_pkg_catalog_apiserver_repository.DeleteApplicationResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application": { "type": "object", "properties": { @@ -1411,19 +1421,6 @@ } } }, - "internal_pkg_catalog_apiserver_handlers.UpdateApplicationRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "maxLength": 100, - "minLength": 3 - } - } - }, "internal_pkg_catalog_apiserver_handlers.loginReq": { "type": "object", "required": [ diff --git a/ai-services/docs/swagger.yaml b/ai-services/docs/swagger.yaml index 4e373deab..2ca2ecdde 100644 --- a/ai-services/docs/swagger.yaml +++ b/ai-services/docs/swagger.yaml @@ -61,6 +61,15 @@ definitions: - service_id - type type: object + github_com_project-ai-services_ai-services_internal_pkg_catalog_apiserver_repository.DeleteApplicationResponse: + properties: + id: + type: string + message: + type: string + status: + type: string + type: object github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application: properties: created_at: @@ -341,15 +350,6 @@ definitions: memory: $ref: '#/definitions/github_com_project-ai-services_ai-services_internal_pkg_models.MemoryInfo' type: object - internal_pkg_catalog_apiserver_handlers.UpdateApplicationRequest: - properties: - name: - maxLength: 100 - minLength: 3 - type: string - required: - - name - type: object internal_pkg_catalog_apiserver_handlers.loginReq: properties: password: @@ -474,32 +474,56 @@ paths: - Applications /applications/{id}: delete: - description: Delete a specific application and all its resources + description: Initiates async deletion of an application and all its associated + resources parameters: - - description: Application ID + - description: Application UUID in: path name: id required: true type: string + - description: Skip infrastructure cleanup + in: query + name: skip-cleanup + type: boolean + produces: + - application/json responses: - "200": - description: Application deleted + "202": + description: Accepted schema: - additionalProperties: true - type: object + $ref: '#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_apiserver_repository.DeleteApplicationResponse' + "400": + description: Invalid application ID + schema: + $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' "404": description: Application not found schema: - additionalProperties: true - type: object + $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + "409": + description: Application already being deleted + schema: + $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' security: - BearerAuth: [] summary: Delete application tags: - Applications get: - description: Retrieves a single application by its unique identifier for the - authenticated user + description: Get detailed information about a specific application parameters: - description: Application ID in: path @@ -510,69 +534,43 @@ paths: - application/json responses: "200": - description: OK + description: Application details schema: - $ref: '#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + additionalProperties: true + type: object "404": description: Application not found schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + additionalProperties: true + type: object security: - BearerAuth: [] - summary: Get application by ID + summary: Get application details tags: - Applications put: consumes: - application/json - description: Updates the display name of an existing application + description: Update an existing application's configuration parameters: - - description: Application ID (UUID) + - description: Application ID in: path name: id required: true type: string - - description: Update request - in: body - name: body - required: true - schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.UpdateApplicationRequest' produces: - application/json responses: "200": - description: OK - schema: - $ref: '#/definitions/github_com_project-ai-services_ai-services_internal_pkg_catalog_types.Application' - "400": - description: Invalid request body or name validation failed - schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' - "403": - description: User doesn't own this application + description: Application updated schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + additionalProperties: true + type: object "404": description: Application not found schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/internal_pkg_catalog_apiserver_handlers.ErrorResponse' + additionalProperties: true + type: object security: - BearerAuth: [] summary: Update application diff --git a/ai-services/internal/pkg/catalog/apiserver/handlers/application_handler.go b/ai-services/internal/pkg/catalog/apiserver/handlers/application_handler.go index 6fe3eec5f..628b57e15 100644 --- a/ai-services/internal/pkg/catalog/apiserver/handlers/application_handler.go +++ b/ai-services/internal/pkg/catalog/apiserver/handlers/application_handler.go @@ -4,12 +4,11 @@ import ( "fmt" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/project-ai-services/ai-services/internal/pkg/catalog/apiserver/middleware" - "github.com/project-ai-services/ai-services/internal/pkg/catalog/apiserver/models" "github.com/project-ai-services/ai-services/internal/pkg/catalog/apiserver/repository" dbmodels "github.com/project-ai-services/ai-services/internal/pkg/catalog/db/models" @@ -24,10 +23,6 @@ type ApplicationHandler struct { appService *repository.ApplicationService } -type UpdateApplicationRequest struct { - Name string `json:"name" binding:"required,min=3,max=100"` -} - // NewApplicationHandler creates a new application handler. func NewApplicationHandler(appService *repository.ApplicationService) *ApplicationHandler { return &ApplicationHandler{ @@ -98,62 +93,6 @@ func (h *ApplicationHandler) ListApplications(c *gin.Context) { c.JSON(http.StatusOK, response) } -// UpdateApplication godoc -// -// @Summary Update application -// @Description Updates the display name of an existing application -// @Tags Applications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Application ID (UUID)" -// @Param body body UpdateApplicationRequest true "Update request" -// @Success 200 {object} types.Application -// @Failure 400 {object} ErrorResponse "Invalid request body or name validation failed" -// @Failure 401 {object} ErrorResponse "Unauthorized" -// @Failure 403 {object} ErrorResponse "User doesn't own this application" -// @Failure 404 {object} ErrorResponse "Application not found" -// @Failure 500 {object} ErrorResponse "Internal Server Error" -// @Router /applications/{id} [put] -func (h *ApplicationHandler) UpdateApplication(c *gin.Context) { - appID, err := uuid.Parse(c.Param("id")) - if err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid application ID format"}) - - return - } - var req UpdateApplicationRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Invalid request body: %v", err)}) - - return - } - // Get authenticated user ID - userID := c.GetString(middleware.CtxUserIDKey) - if userID == "" { - c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "User not authenticated"}) - - return - } - updatedApp, err := h.appService.UpdateApplication(c.Request.Context(), appID, userID, req.Name) - if err != nil { - if err == repository.ErrApplicationNotFound { - c.JSON(http.StatusNotFound, ErrorResponse{Error: "Application not found"}) - - return - } - if err == repository.ErrUnauthorized { - c.JSON(http.StatusForbidden, ErrorResponse{Error: "User doesn't own this application"}) - - return - } - c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("Failed to update application: %v", err)}) - - return - } - c.JSON(http.StatusOK, updatedApp) -} - // CreateApplication godoc // // @Summary Create new application @@ -195,44 +134,64 @@ func (h *ApplicationHandler) CreateApplication(c *gin.Context) { c.JSON(http.StatusAccepted, response) } -// GetApplicationByID godoc +// DeleteApplication godoc // -// @Summary Get application by ID -// @Description Retrieves a single application by its unique identifier for the authenticated user +// @Summary Delete application +// @Description Initiates async deletion of an application and all its associated resources // @Tags Applications // @Produce json // @Security BearerAuth -// @Param id path string true "Application ID" -// @Success 200 {object} types.Application -// @Failure 401 {object} ErrorResponse "Unauthorized" -// @Failure 404 {object} ErrorResponse "Application not found" -// @Failure 500 {object} ErrorResponse "Internal Server Error" -// @Router /applications/{id} [get] -func (h *ApplicationHandler) GetApplicationByID(c *gin.Context) { +// @Param id path string true "Application UUID" +// @Param skip-cleanup query bool false "Skip infrastructure cleanup" +// @Success 202 {object} repository.DeleteApplicationResponse +// @Failure 400 {object} ErrorResponse "Invalid application ID" +// @Failure 401 {object} ErrorResponse "Unauthorized" +// @Failure 403 {object} ErrorResponse "Forbidden" +// @Failure 404 {object} ErrorResponse "Application not found" +// @Failure 409 {object} ErrorResponse "Application already being deleted" +// @Failure 500 {object} ErrorResponse "Internal Server Error" +// @Router /applications/{id} [delete] +func (h *ApplicationHandler) DeleteApplication(c *gin.Context) { appID, err := uuid.Parse(c.Param("id")) if err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid application ID format"}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid application ID format, expected UUID"}) return } - // Call service layer - response, err := h.appService.GetApplicationByID(c.Request.Context(), appID) - if err != nil { - if err == repository.ErrApplicationNotFound { - c.JSON(http.StatusNotFound, ErrorResponse{Error: "Application not found"}) + skipCleanup := c.Query("skip-cleanup") == "true" - return - } + userIDVal, exists := c.Get(middleware.CtxUserIDKey) + if !exists { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "authentication required"}) - c.JSON(http.StatusInternalServerError, ErrorResponse{ - Error: fmt.Sprintf("Failed to get application: %v", err), - }) + return + } + userID := userIDVal.(string) + + response, err := h.appService.DeleteApplication(c.Request.Context(), appID, userID, skipCleanup) + if err != nil { + h.handleDeleteError(c, err) return } - c.JSON(http.StatusOK, response) + c.JSON(http.StatusAccepted, response) +} + +func (s *ApplicationHandler) handleDeleteError(c *gin.Context, err error) { + msg := err.Error() + + switch { + case strings.Contains(msg, "not found"): + c.JSON(http.StatusNotFound, ErrorResponse{Error: msg}) + case strings.Contains(msg, "forbidden"): + c.JSON(http.StatusForbidden, ErrorResponse{Error: msg}) + case strings.Contains(msg, "conflict"): + c.JSON(http.StatusConflict, ErrorResponse{Error: msg}) + default: + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + } } // Made with Bob diff --git a/ai-services/internal/pkg/catalog/apiserver/repository/application_service.go b/ai-services/internal/pkg/catalog/apiserver/repository/application_service.go index bd53bd0e9..30dd80ca6 100644 --- a/ai-services/internal/pkg/catalog/apiserver/repository/application_service.go +++ b/ai-services/internal/pkg/catalog/apiserver/repository/application_service.go @@ -2,11 +2,9 @@ package repository import ( "context" - "errors" "fmt" "github.com/google/uuid" - "github.com/jackc/pgx/v5" "github.com/project-ai-services/ai-services/internal/pkg/catalog" apimodels "github.com/project-ai-services/ai-services/internal/pkg/catalog/apiserver/models" "github.com/project-ai-services/ai-services/internal/pkg/catalog/constants" @@ -15,26 +13,36 @@ import ( "github.com/project-ai-services/ai-services/internal/pkg/catalog/types" ) -var ( - ErrApplicationNotFound = errors.New("application not found") - ErrUnauthorized = errors.New("user not authorized") -) - // ApplicationService provides business logic for application operations. type ApplicationService struct { - appRepo dbrepo.ApplicationRepository - serviceDependencyRepo dbrepo.ServiceDependencyRepository - componentRepo dbrepo.ComponentRepository - provider *catalog.CatalogProvider + appRepo dbrepo.ApplicationRepository + serviceRepo dbrepo.ServiceRepository + depRepo dbrepo.ServiceDependencyRepository + componentRepo dbrepo.ComponentRepository + provider *catalog.CatalogProvider +} + +// DeleteApplicationResponse is the response body for a delete application request. +type DeleteApplicationResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Message string `json:"message"` } // NewApplicationService creates a new application service. -func NewApplicationService(appRepo dbrepo.ApplicationRepository, serviceDependencyRepo dbrepo.ServiceDependencyRepository, componentRepo dbrepo.ComponentRepository, provider *catalog.CatalogProvider) *ApplicationService { +func NewApplicationService( + appRepo dbrepo.ApplicationRepository, + serviceRepo dbrepo.ServiceRepository, + depRepo dbrepo.ServiceDependencyRepository, + componentRepo dbrepo.ComponentRepository, + provider *catalog.CatalogProvider, +) *ApplicationService { return &ApplicationService{ - appRepo: appRepo, - serviceDependencyRepo: serviceDependencyRepo, - componentRepo: componentRepo, - provider: provider, + appRepo: appRepo, + serviceRepo: serviceRepo, + depRepo: depRepo, + componentRepo: componentRepo, + provider: provider, } } @@ -191,144 +199,114 @@ func ValidatePaginationParams(page, pageSize int) (int, int, error) { return page, pageSize, nil } -func (s *ApplicationService) UpdateApplication(ctx context.Context, id uuid.UUID, userID, newName string) (*types.Application, error) { - app, err := s.appRepo.GetByID(ctx, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrApplicationNotFound - } - - return nil, fmt.Errorf("failed to get application: %w", err) - } - if app.CreatedBy != userID { - return nil, ErrUnauthorized - } - err = s.appRepo.UpdateDeploymentName(ctx, id, newName) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrApplicationNotFound - } - - return nil, fmt.Errorf("failed to update application name: %w", err) - } - updatedApp, err := s.appRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated application %w", err) - } - - appData, err := s.buildApplication(*updatedApp) - if err != nil { - return nil, err - } - - return &appData, nil -} - // CreateApplication creates a new application with the given configuration. func (s *ApplicationService) CreateApplication(ctx context.Context, req apimodels.CreateApplicationRequest) (*apimodels.CreateApplicationResponse, error) { // to be implemented return nil, nil } -// GetApplicationByID retrieves application details by ID including all services and components. -func (s *ApplicationService) GetApplicationByID(ctx context.Context, id uuid.UUID) (*types.Application, error) { - // Fetch application from database +// DeleteApplication initiates async deletion of an application. +func (s *ApplicationService) DeleteApplication(ctx context.Context, id uuid.UUID, user string, skipCleanup bool) (*DeleteApplicationResponse, error) { app, err := s.appRepo.GetByID(ctx, id) + if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrApplicationNotFound - } + return nil, fmt.Errorf("not found: %w", err) + } - return nil, fmt.Errorf("failed to get application: %w", err) + if app.CreatedBy != user { + return nil, fmt.Errorf("forbidden: user does not own this application") } - // Build complete response with services and components - return s.buildGetApplicationResponse(ctx, app) -} -// buildGetApplicationResponse constructs the application response with type info and nested services. -func (s *ApplicationService) buildGetApplicationResponse(ctx context.Context, app *models.Application) (*types.Application, error) { - // Get application type display name from catalog metadata - typeName, err := s.getApplicationType(app.CatalogID, app.DeploymentType) - if err != nil { - return nil, fmt.Errorf("failed to get application type for catalog_id '%s': %w", app.CatalogID, err) + if app.Status == models.ApplicationStatusDeleting { + return nil, fmt.Errorf("conflict: application is already being deleted") } - // Build base application response - appresponse := &types.Application{ - ID: app.ID.String(), - Name: app.Name, - DeploymentType: string(app.DeploymentType), - Type: typeName, - Status: string(app.Status), - Message: app.Message, - CreatedAt: app.CreatedAt.Format(constants.RFC3339WithTimezone), - UpdatedAt: app.UpdatedAt.Format(constants.RFC3339WithTimezone), + + if err := s.appRepo.UpdateStatus(ctx, id, models.ApplicationStatusDeleting, "Deletion initiated"); err != nil { + return nil, err } - // Load services with their components if present - if len(app.Services) > 0 { - appresponse.Services, err = s.loadApplicationServices(ctx, app.Services) - if err != nil { - return nil, fmt.Errorf("failed to get application services: %w", err) + for _, svc := range app.Services { + if err := s.serviceRepo.UpdateStatus(ctx, svc.ID, models.ApplicationStatusDeleting); err != nil { + return nil, fmt.Errorf("failed to set service %s to deleting: %w", svc.ID, err) } } - return appresponse, nil + go s.performDeletion(context.Background(), id, app.Services, skipCleanup) + + return &DeleteApplicationResponse{ + ID: id.String(), + Status: string(models.ApplicationStatusDeleting), + Message: "Deletion initiated successfully", + }, nil } -// loadApplicationServices transforms service models to API response objects with components. -func (s *ApplicationService) loadApplicationServices(ctx context.Context, services []models.Service) ([]types.ApplicationService, error) { - appServices := []types.ApplicationService{} - for _, service := range services { - // Build application service response - appService := types.ApplicationService{ - ID: service.ID.String(), - Type: service.CatalogID, - Endpoints: service.Endpoints, - Version: service.Version, - CreatedAt: service.CreatedAt.Format(constants.RFC3339WithTimezone), - UpdatedAt: service.UpdatedAt.Format(constants.RFC3339WithTimezone), - } +// performDeletion carries out the async cascade deletion for an application. +// +//nolint:cyclop +func (s *ApplicationService) performDeletion(ctx context.Context, appID uuid.UUID, services []models.Service, skipCleanup bool) { + _ = skipCleanup // reserved for pod teardown once Create flow is ready + serviceIDs := make(map[uuid.UUID]bool, len(services)) + componentCandidates := make(map[uuid.UUID]bool) + + for _, svc := range services { + serviceIDs[svc.ID] = true - // Get all dependencies for this service - serviceDependencies, err := s.serviceDependencyRepo.GetDependenciesByServiceID(ctx, service.ID) + deps, err := s.depRepo.GetDependenciesByServiceID(ctx, svc.ID) if err != nil { - return nil, fmt.Errorf("failed to get application dependencies: %w", err) + _ = s.appRepo.UpdateStatus(ctx, appID, models.ApplicationStatusError, + fmt.Sprintf("failed to get dependencies for service %s: %s", svc.ID, err)) + + return } - // Load component details from dependencies - appService.Component, err = s.loadServiceComponents(ctx, serviceDependencies) - if err != nil { - return nil, err + for _, dep := range deps { + if dep.DependencyType == models.DependencyTypeComponent { + componentCandidates[dep.DependencyID] = true + } } - appServices = append(appServices, appService) } - return appServices, nil -} + var orphanedComponents []uuid.UUID -// loadServiceComponents extracts component details from service dependencies. -func (s *ApplicationService) loadServiceComponents(ctx context.Context, sd []models.ServiceDependency) ([]types.ServiceComponentResp, error) { - components := []types.ServiceComponentResp{} - for _, dependency := range sd { - // Only process component-type dependencies - if dependency.DependencyType == models.DependencyTypeComponent { - // Fetch component details from database - component, err := s.componentRepo.GetByID(ctx, dependency.DependencyID) - if err != nil { - return nil, fmt.Errorf("failed to get component: %w", err) - } + for componentID := range componentCandidates { + consumers, err := s.depRepo.GetServicesByDependency(ctx, componentID, models.DependencyTypeComponent) + if err != nil { + _ = s.appRepo.UpdateStatus(ctx, appID, models.ApplicationStatusError, + fmt.Sprintf("failed to check consumers of component %s: %s", componentID, err)) + + return + } + + onlyUsedByThisApp := true + for _, consumerID := range consumers { + if !serviceIDs[consumerID] { + onlyUsedByThisApp = false - // Transform to response object - temp := types.ServiceComponentResp{ - Type: component.Type, - Provider: component.Provider, - Metadata: component.Metadata, + break } - components = append(components, temp) + } + + if onlyUsedByThisApp { + orphanedComponents = append(orphanedComponents, componentID) } } - return components, nil -} + if err := s.appRepo.Delete(ctx, appID); err != nil { + _ = s.appRepo.UpdateStatus(ctx, appID, models.ApplicationStatusError, + fmt.Sprintf("failed to delete application: %s", err)) + + return + } -// Made with Bob + for _, componentID := range orphanedComponents { + if err := s.componentRepo.Delete(ctx, componentID); err != nil { + fmt.Printf("warning: failed to delete orphaned component %s: %s\n", componentID, err) + } + } + + // TODO: teardown pods/containers, skipCleanup flag + // Deferred until Create Application flow is ready + // if !skipCleanup { + // s.teardownPods(ctx, appID, services) + // } +} diff --git a/ai-services/internal/pkg/catalog/apiserver/router.go b/ai-services/internal/pkg/catalog/apiserver/router.go index e36b28fd8..e8b743c79 100644 --- a/ai-services/internal/pkg/catalog/apiserver/router.go +++ b/ai-services/internal/pkg/catalog/apiserver/router.go @@ -22,17 +22,11 @@ func CreateRouter(authSvc auth.Service, tokenMgr *auth.TokenManager, blacklist r c.JSON(http.StatusOK, gin.H{"message": "ok"}) }) - // Expose /health for liveness probes - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "ok"}) - }) - // Swagger documentation endpoint router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) authHandler := handlers.NewAuthHandler(authSvc) catalogHandler := handlers.NewCatalogHandler() - resourcesHandler := handlers.NewResourcesHandler() applicationHandler := handlers.NewApplicationHandler(appService) v1 := router.Group("/api/v1") @@ -47,7 +41,6 @@ func CreateRouter(authSvc auth.Service, tokenMgr *auth.TokenManager, blacklist r catalog := v1.Group("") catalog.Use(middleware.AuthMiddleware(tokenMgr, blacklist)) { - catalog.GET("/resources", resourcesHandler.GetResources) catalog.GET("/architectures", catalogHandler.ListArchitectures) catalog.GET("/architectures/:id", catalogHandler.GetArchitectureDetails) catalog.GET("/architectures/:id/deploy-options", catalogHandler.GetArchitectureDeployOptions) @@ -62,27 +55,44 @@ func CreateRouter(authSvc auth.Service, tokenMgr *auth.TokenManager, blacklist r { // Implemented endpoints applications.GET("/", applicationHandler.ListApplications) - applications.GET("/:id", applicationHandler.GetApplicationByID) applications.POST("/", applicationHandler.CreateApplication) - applications.PUT("/:id", applicationHandler.UpdateApplication) // Draft endpoints - placeholders for future implementation - applications.DELETE("/:id", deleteApplication) + applications.GET("/:id", getApplication) + applications.PUT("/:id", updateApplication) + applications.DELETE("/:id", applicationHandler.DeleteApplication) } return router } -// DeleteApplication godoc +// GetApplication godoc +// +// @Summary Get application details +// @Description Get detailed information about a specific application +// @Tags Applications +// @Produce json +// @Security BearerAuth +// @Param id path string true "Application ID" +// @Success 200 {object} map[string]interface{} "Application details" +// @Failure 404 {object} map[string]interface{} "Application not found" +// @Router /applications/{id} [get] +func getApplication(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "This is a placeholder endpoint for " + c.FullPath()}) +} + +// UpdateApplication godoc // -// @Summary Delete application -// @Description Delete a specific application and all its resources +// @Summary Update application +// @Description Update an existing application's configuration // @Tags Applications +// @Accept json +// @Produce json // @Security BearerAuth // @Param id path string true "Application ID" -// @Success 200 {object} map[string]interface{} "Application deleted" +// @Success 200 {object} map[string]interface{} "Application updated" // @Failure 404 {object} map[string]interface{} "Application not found" -// @Router /applications/{id} [delete] -func deleteApplication(c *gin.Context) { +// @Router /applications/{id} [put] +func updateApplication(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "This is a placeholder endpoint for " + c.FullPath()}) } diff --git a/ai-services/internal/pkg/catalog/db/repository/application_repo.go b/ai-services/internal/pkg/catalog/db/repository/application_repo.go index cc2ad9421..15d2958ad 100644 --- a/ai-services/internal/pkg/catalog/db/repository/application_repo.go +++ b/ai-services/internal/pkg/catalog/db/repository/application_repo.go @@ -37,6 +37,8 @@ type ApplicationRepository interface { UpdateDeploymentName(ctx context.Context, id uuid.UUID, name string) error // Delete removes an application from the database. Delete(ctx context.Context, id uuid.UUID) error + // update status for a deletion + UpdateStatus(ctx context.Context, id uuid.UUID, status models.ApplicationStatus, message string) error } // applicationRepo implements ApplicationRepository using pgx. @@ -323,7 +325,7 @@ func (r *applicationRepo) GetByName(ctx context.Context, name string) (*models.A query := ` SELECT a.id, a.name, a.catalog_id, a.deployment_type, a.status, a.message, a.created_by, a.created_at, a.updated_at, - s.id, s.app_id, s.type, s.status, s.endpoints, s.version, s.created_at, s.updated_at + s.id, s.app_id, s.catalog_id, s.status, s.endpoints, s.version, s.created_at, s.updated_at FROM applications a LEFT JOIN services s ON a.id = s.app_id WHERE a.name = $1 @@ -408,4 +410,19 @@ func (r *applicationRepo) Delete(ctx context.Context, id uuid.UUID) error { return nil } +func (r *applicationRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status models.ApplicationStatus, message string) error { + query := `UPDATE applications SET status=$2, message=$3, updated_at=NOW() WHERE id=$1` + + result, err := r.pool.Exec(ctx, query, id, status, message) + if err != nil { + return fmt.Errorf("failed to update application status: %w", err) + } + + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + + return nil +} + // Made with Bob diff --git a/ai-services/internal/pkg/catalog/db/repository/service_repo.go b/ai-services/internal/pkg/catalog/db/repository/service_repo.go index 5965404e8..09fa093f9 100644 --- a/ai-services/internal/pkg/catalog/db/repository/service_repo.go +++ b/ai-services/internal/pkg/catalog/db/repository/service_repo.go @@ -22,6 +22,8 @@ type ServiceRepository interface { GetByAppID(ctx context.Context, appID uuid.UUID) ([]models.Service, error) // Update updates a service in the database. Update(ctx context.Context, service *models.Service) error + // UpdateStatus updates the status of a service by ID. + UpdateStatus(ctx context.Context, id uuid.UUID, status models.ApplicationStatus) error } // serviceRepo implements ServiceRepository using pgx. @@ -190,4 +192,20 @@ func (r *serviceRepo) Update(ctx context.Context, service *models.Service) error return nil } +// UpdateStatus updates the status of a service by ID. +func (r *serviceRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status models.ApplicationStatus) error { + query := `UPDATE services SET status=$2, updated_at=NOW() WHERE id=$1` + + result, err := r.pool.Exec(ctx, query, id, status) + if err != nil { + return fmt.Errorf("failed to update service status: %w", err) + } + + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + + return nil +} + // Made with Bob diff --git a/services/similarity/requirements-test.txt b/services/similarity/requirements-test.txt new file mode 100644 index 000000000..71d1abf5e --- /dev/null +++ b/services/similarity/requirements-test.txt @@ -0,0 +1,13 @@ +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 +pytest-mock>=3.11.0 +httpx>=0.24.0 +fastapi>=0.104.0 +starlette>=0.27.0 +uvicorn==0.38.0 +requests==2.33.0 +pydantic>=2.0.0 +pydantic-settings==2.13.1 +opensearch-py==3.0.0 +numpy==2.4.1