From f4a1158e4c8fd38484640ed83b9a985315de751e Mon Sep 17 00:00:00 2001 From: thivindu Date: Wed, 19 Nov 2025 10:26:45 +0530 Subject: [PATCH 1/3] Import and Create API from OpenAPI definition --- platform-api/src/internal/dto/openapi.go | 7 ++ platform-api/src/internal/handler/api.go | 144 +++++++++++++++++++++++ platform-api/src/internal/service/api.go | 75 ++++++++++++ platform-api/src/internal/utils/api.go | 97 +++++++++++++++ platform-api/src/resources/openapi.yaml | 72 ++++++++++++ 5 files changed, 395 insertions(+) diff --git a/platform-api/src/internal/dto/openapi.go b/platform-api/src/internal/dto/openapi.go index fdbf04386..bc6db050d 100644 --- a/platform-api/src/internal/dto/openapi.go +++ b/platform-api/src/internal/dto/openapi.go @@ -35,3 +35,10 @@ type OpenAPIValidationResponse struct { Errors []string `json:"errors,omitempty"` API *API `json:"api,omitempty"` } + +// ImportOpenAPIRequest represents the request for importing an OpenAPI definition +type ImportOpenAPIRequest struct { + URL string `form:"url"` // Optional: URL to fetch OpenAPI definition + Definition *multipart.FileHeader `form:"definition"` // Optional: Uploaded OpenAPI file (JSON/YAML) + API API `json:"api"` // API details for the imported definition +} diff --git a/platform-api/src/internal/handler/api.go b/platform-api/src/internal/handler/api.go index 24155b6a9..dee75df0d 100644 --- a/platform-api/src/internal/handler/api.go +++ b/platform-api/src/internal/handler/api.go @@ -18,6 +18,7 @@ package handler import ( + "encoding/json" "errors" "log" "net/http" @@ -26,6 +27,7 @@ import ( "platform-api/src/internal/middleware" "platform-api/src/internal/service" "platform-api/src/internal/utils" + "strings" "github.com/gin-gonic/gin" ) @@ -810,6 +812,147 @@ func (h *APIHandler) ValidateOpenAPI(c *gin.Context) { c.JSON(http.StatusOK, response) } +// ImportOpenAPI handles POST /import/open-api and imports an API from OpenAPI definition +func (h *APIHandler) ImportOpenAPI(c *gin.Context) { + orgId, exists := middleware.GetOrganizationFromContext(c) + if !exists { + c.JSON(http.StatusUnauthorized, utils.NewErrorResponse(401, "Unauthorized", + "Organization claim not found in token")) + return + } + + // Parse multipart form + err := c.Request.ParseMultipartForm(10 << 20) // 10 MB max + if err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Failed to parse multipart form")) + return + } + + var req dto.ImportOpenAPIRequest + + // Get URL from form if provided + if url := c.PostForm("url"); url != "" { + req.URL = url + } + + // Get definition file from form if provided + if file, header, err := c.Request.FormFile("definition"); err == nil { + req.Definition = header + defer file.Close() + } + + // Validate that at least one input is provided + if req.URL == "" && req.Definition == nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Either URL or definition file must be provided")) + return + } + + // Get API details from form data (JSON string in 'api' field) + apiJSON := c.PostForm("api") + if apiJSON == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API details are required")) + return + } + + // Parse API details from JSON string + if err := json.Unmarshal([]byte(apiJSON), &req.API); err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid API details: "+err.Error())) + return + } + + if req.API.Name == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API name is required")) + return + } + if req.API.Context == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API context is required")) + return + } + if req.API.Version == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "API version is required")) + return + } + if req.API.ProjectID == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Project ID is required")) + return + } + + // Import API from OpenAPI definition + api, err := h.apiService.ImportFromOpenAPI(&req, orgId) + if err != nil { + if errors.Is(err, constants.ErrAPIAlreadyExists) { + c.JSON(http.StatusConflict, utils.NewErrorResponse(409, "Conflict", + "API already exists in the project")) + return + } + if errors.Is(err, constants.ErrProjectNotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "Not Found", + "Project not found")) + return + } + if errors.Is(err, constants.ErrInvalidAPIName) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid API name format")) + return + } + if errors.Is(err, constants.ErrInvalidAPIContext) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid API context format")) + return + } + if errors.Is(err, constants.ErrInvalidAPIVersion) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid API version format")) + return + } + if errors.Is(err, constants.ErrInvalidLifecycleState) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid lifecycle status")) + return + } + if errors.Is(err, constants.ErrInvalidAPIType) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid API type")) + return + } + if errors.Is(err, constants.ErrInvalidTransport) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid transport protocol")) + return + } + // Handle OpenAPI-specific errors + if strings.Contains(err.Error(), "failed to fetch OpenAPI definition") { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Failed to fetch OpenAPI definition from URL")) + return + } + if strings.Contains(err.Error(), "failed to validate OpenAPI definition") { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Invalid OpenAPI definition")) + return + } + if strings.Contains(err.Error(), "failed to parse OpenAPI definition") { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Failed to parse OpenAPI definition")) + return + } + + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to import API from OpenAPI definition")) + return + } + + c.JSON(http.StatusCreated, api) +} + // RegisterRoutes registers all API routes func (h *APIHandler) RegisterRoutes(r *gin.Engine) { // API routes @@ -830,6 +973,7 @@ func (h *APIHandler) RegisterRoutes(r *gin.Engine) { importGroup := r.Group("/api/v1/import") { importGroup.POST("/api-project", h.ImportAPIProject) + importGroup.POST("/open-api", h.ImportOpenAPI) } validateGroup := r.Group("/api/v1/validate") { diff --git a/platform-api/src/internal/service/api.go b/platform-api/src/internal/service/api.go index 46f76e48a..76c880f0a 100644 --- a/platform-api/src/internal/service/api.go +++ b/platform-api/src/internal/service/api.go @@ -1375,3 +1375,78 @@ func (s *APIService) ValidateOpenAPIDefinition(req *dto.ValidateOpenAPIRequest) return response, nil } + +// ImportFromOpenAPI imports an API from an OpenAPI definition +func (s *APIService) ImportFromOpenAPI(req *dto.ImportOpenAPIRequest, orgId string) (*dto.API, error) { + var content []byte + var err error + var errorList []string + + // If URL is provided, fetch content from URL + if req.URL != "" { + content, err = s.apiUtil.FetchOpenAPIFromURL(req.URL) + if err != nil { + content = make([]byte, 0) + errorList = append(errorList, fmt.Sprintf("failed to fetch OpenAPI from URL: %s", err.Error())) + } + } + + // If definition file is provided, read from file + if req.Definition != nil { + file, err := req.Definition.Open() + if err != nil { + errorList = append(errorList, fmt.Sprintf("failed to open definition file: %s", err.Error())) + return nil, fmt.Errorf(strings.Join(errorList, "; ")) + } + defer file.Close() + + content, err = io.ReadAll(file) + if err != nil { + errorList = append(errorList, fmt.Sprintf("failed to read definition file: %s", err.Error())) + return nil, fmt.Errorf(strings.Join(errorList, "; ")) + } + } + + // If neither URL nor file is provided + if len(content) == 0 { + errorList = append(errorList, "either URL or definition file must be provided") + return nil, fmt.Errorf(strings.Join(errorList, "; ")) + } + + // Validate and parse the OpenAPI definition + apiDetails, err := s.apiUtil.ValidateAndParseOpenAPI(content) + if err != nil { + return nil, fmt.Errorf("failed to validate OpenAPI definition: %w", err) + } + + // Merge provided API details with extracted details from OpenAPI + mergedAPI := s.apiUtil.MergeAPIDetails(&req.API, apiDetails) + + // Create API using existing CreateAPI logic + createReq := &CreateAPIRequest{ + Name: mergedAPI.Name, + DisplayName: mergedAPI.DisplayName, + Description: mergedAPI.Description, + Context: mergedAPI.Context, + Version: mergedAPI.Version, + Provider: mergedAPI.Provider, + ProjectID: mergedAPI.ProjectID, + LifeCycleStatus: mergedAPI.LifeCycleStatus, + Type: mergedAPI.Type, + Transport: mergedAPI.Transport, + MTLS: mergedAPI.MTLS, + Security: mergedAPI.Security, + CORS: mergedAPI.CORS, + BackendServices: mergedAPI.BackendServices, + APIRateLimiting: mergedAPI.APIRateLimiting, + Operations: mergedAPI.Operations, + } + + // Validate the merged API details + if err := s.validateCreateAPIRequest(createReq); err != nil { + return nil, err + } + + // Create the API + return s.CreateAPI(createReq, orgId) +} diff --git a/platform-api/src/internal/utils/api.go b/platform-api/src/internal/utils/api.go index 5d0397629..9a8ad2471 100644 --- a/platform-api/src/internal/utils/api.go +++ b/platform-api/src/internal/utils/api.go @@ -1432,3 +1432,100 @@ func (u *APIUtil) convertSwagger2ToBackendServices(host, basePath string, scheme return backendServices } + +// ValidateAndParseOpenAPI validates and parses OpenAPI definition content +func (u *APIUtil) ValidateAndParseOpenAPI(content []byte) (*dto.API, error) { + // Validate the OpenAPI definition + if err := u.ValidateOpenAPIDefinition(content); err != nil { + return nil, fmt.Errorf("invalid OpenAPI definition: %w", err) + } + + // Parse and extract API details + api, err := u.ParseAPIDefinition(content) + if err != nil { + return nil, fmt.Errorf("failed to parse OpenAPI definition: %w", err) + } + + return api, nil +} + +// MergeAPIDetails merges user-provided API details with extracted OpenAPI details +// User-provided details take precedence over extracted details +func (u *APIUtil) MergeAPIDetails(userAPI *dto.API, extractedAPI *dto.API) *dto.API { + merged := &dto.API{} + + // Required fields from user input (these must be provided) + merged.Name = userAPI.Name + merged.Context = userAPI.Context + merged.Version = userAPI.Version + merged.ProjectID = userAPI.ProjectID + + // Optional fields - use user input if provided, otherwise use extracted values + if userAPI.DisplayName != "" { + merged.DisplayName = userAPI.DisplayName + } else { + merged.DisplayName = extractedAPI.DisplayName + } + + if userAPI.Description != "" { + merged.Description = userAPI.Description + } else { + merged.Description = extractedAPI.Description + } + + if userAPI.Provider != "" { + merged.Provider = userAPI.Provider + } else { + merged.Provider = extractedAPI.Provider + } + + if userAPI.Type != "" { + merged.Type = userAPI.Type + } else { + merged.Type = extractedAPI.Type + } + + if len(userAPI.Transport) > 0 { + merged.Transport = userAPI.Transport + } else { + merged.Transport = extractedAPI.Transport + } + + if userAPI.LifeCycleStatus != "" { + merged.LifeCycleStatus = userAPI.LifeCycleStatus + } else { + merged.LifeCycleStatus = extractedAPI.LifeCycleStatus + } + + if len(userAPI.BackendServices) > 0 { + merged.BackendServices = userAPI.BackendServices + } else { + merged.BackendServices = extractedAPI.BackendServices + } + + // Use extracted operations from OpenAPI + merged.Operations = extractedAPI.Operations + + // Use user-provided configuration if available + if userAPI.MTLS != nil { + merged.MTLS = userAPI.MTLS + } + if userAPI.Security != nil { + merged.Security = userAPI.Security + } + if userAPI.CORS != nil { + merged.CORS = userAPI.CORS + } + if userAPI.APIRateLimiting != nil { + merged.APIRateLimiting = userAPI.APIRateLimiting + } + + // Copy boolean fields from user input + merged.HasThumbnail = userAPI.HasThumbnail + merged.IsDefaultVersion = userAPI.IsDefaultVersion + merged.IsRevision = userAPI.IsRevision + merged.RevisionedAPIID = userAPI.RevisionedAPIID + merged.RevisionID = userAPI.RevisionID + + return merged +} diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index e47350a2f..8072c582f 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -296,6 +296,41 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /import/open-api: + post: + summary: Import and create API from OpenAPI definition + description: | + Imports an OpenAPI definition into the platform from a provided URL or file upload. + An API is created from the imported OpenAPI definition and is associated with a specified project within the + organization in the JWT token. + operationId: ImportOpenAPI + tags: + - APIs + requestBody: + description: OpenAPI import details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImportOpenAPIRequest' + responses: + '201': + description: OpenAPI imported successfully + content: + application/json: + schema: + $ref: '#/components/schemas/API' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/InternalServerError' + /import/api-project: post: summary: Import API project @@ -2568,6 +2603,43 @@ components: - required: [url] - required: [definition] + ImportOpenAPIRequest: + type: object + required: + - api + description: | + Multipart form data request for importing OpenAPI definition. All fields are provided as form data. + Either 'url' or 'definition' must be provided to specify the OpenAPI source, and 'api' is required + to provide the API details for creation. If the OpenAPI definition is valid and successfully imported, + and the api details are valid an API will be created. + properties: + url: + type: string + format: uri + description: | + Form field: URL to fetch the OpenAPI definition from. + Provide this as a form field when importing from a URL. + example: "https://petstore3.swagger.io/api/v3/openapi.json" + definition: + type: string + format: binary + description: | + Form field: OpenAPI definition file upload (YAML or JSON). + Provide this as a file upload field when importing from a local file. + api: + allOf: + - $ref: '#/components/schemas/API' + - type: object + description: API details for the imported project + required: + - name + - context + - version + - projectId + anyOf: + - required: [ url ] + - required: [ definition ] + APIProjectValidationResponse: type: object required: From 51fd307725ab2dfd14f1164dd831652db074eb54 Mon Sep 17 00:00:00 2001 From: thivindu Date: Wed, 19 Nov 2025 10:33:15 +0530 Subject: [PATCH 2/3] Fix gateway tests --- .../src/internal/service/gateway_test.go | 224 ++++++++++-------- 1 file changed, 120 insertions(+), 104 deletions(-) diff --git a/platform-api/src/internal/service/gateway_test.go b/platform-api/src/internal/service/gateway_test.go index d3f6a3b41..df1fd4b42 100644 --- a/platform-api/src/internal/service/gateway_test.go +++ b/platform-api/src/internal/service/gateway_test.go @@ -18,6 +18,7 @@ package service import ( + "platform-api/src/internal/constants" "testing" ) @@ -26,143 +27,158 @@ func TestValidateGatewayInput(t *testing.T) { service := &GatewayService{} tests := []struct { - name string - orgID string - gatewayName string - displayName string - vhost string - wantErr bool - errContains string + name string + orgID string + gatewayName string + displayName string + vhost string + functionalityType string + wantErr bool + errContains string }{ { - name: "valid input", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod-gateway-01", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: false, + name: "valid input", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: false, }, { - name: "empty organization ID", - orgID: "", - gatewayName: "prod-gateway-01", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "organization ID is required", + name: "empty organization ID", + orgID: "", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "organization ID is required", }, { - name: "invalid organization ID format", - orgID: "not-a-uuid", - gatewayName: "prod-gateway-01", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "invalid organization ID format", + name: "invalid organization ID format", + orgID: "not-a-uuid", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "invalid organization ID format", }, { - name: "empty gateway name", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "gateway name is required", + name: "empty gateway name", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "gateway name is required", }, { - name: "gateway name too short", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "ab", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "at least 3 characters", + name: "gateway name too short", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "ab", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "at least 3 characters", }, { - name: "gateway name too long", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "this-is-a-very-long-gateway-name-that-exceeds-the-maximum-length-of-64-characters", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "must not exceed 64 characters", + name: "gateway name too long", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "this-is-a-very-long-gateway-name-that-exceeds-the-maximum-length-of-64-characters", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "must not exceed 64 characters", }, { - name: "gateway name with uppercase", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "Prod-Gateway-01", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "lowercase letters, numbers, and hyphens", + name: "gateway name with uppercase", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "Prod-Gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "lowercase letters, numbers, and hyphens", }, { - name: "gateway name with special characters", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod_gateway_01", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "lowercase letters, numbers, and hyphens", + name: "gateway name with special characters", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod_gateway_01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "lowercase letters, numbers, and hyphens", }, { - name: "gateway name with leading hyphen", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "-prod-gateway-01", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "cannot start or end with a hyphen", + name: "gateway name with leading hyphen", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "-prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "cannot start or end with a hyphen", }, { - name: "gateway name with trailing hyphen", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod-gateway-01-", - displayName: "Production Gateway 01", - vhost: "api.example.com", - wantErr: true, - errContains: "cannot start or end with a hyphen", + name: "gateway name with trailing hyphen", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01-", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "cannot start or end with a hyphen", }, { - name: "empty display name", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod-gateway-01", - displayName: "", - vhost: "api.example.com", - wantErr: true, - errContains: "display name is required", + name: "empty display name", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "display name is required", }, { - name: "display name too long", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod-gateway-01", - displayName: "This is a very long display name that exceeds the maximum allowed length of 128 characters which should trigger a validation error in the system", - vhost: "api.example.com", - wantErr: true, - errContains: "must not exceed 128 characters", + name: "display name too long", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "This is a very long display name that exceeds the maximum allowed length of 128 characters which should trigger a validation error in the system", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "must not exceed 128 characters", }, { - name: "empty display name", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod-gateway-01", - displayName: "Production Gateway 01", - vhost: "", - wantErr: true, - errContains: "vhost is required", + name: "empty display name", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: true, + errContains: "vhost is required", }, { - name: "display name with spaces (valid)", - orgID: "123e4567-e89b-12d3-a456-426614174000", - gatewayName: "prod-gateway-01", - displayName: "Production Gateway 01 - Main", - vhost: "api.example.com", - wantErr: false, + name: "display name with spaces (valid)", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01 - Main", + vhost: "api.example.com", + functionalityType: constants.GatewayFunctionalityTypeRegular, + wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := service.validateGatewayInput(tt.orgID, tt.gatewayName, tt.displayName, tt.vhost) + err := service.validateGatewayInput(tt.orgID, tt.gatewayName, tt.displayName, tt.vhost, tt.functionalityType) if (err != nil) != tt.wantErr { t.Errorf("validateGatewayInput() error = %v, wantErr %v", err, tt.wantErr) return From 113731d4699f24b84df17911a3481942ab6d9ab2 Mon Sep 17 00:00:00 2001 From: thivindu Date: Wed, 19 Nov 2025 12:06:30 +0530 Subject: [PATCH 3/3] Address CodeRabbit comments --- platform-api/src/internal/dto/openapi.go | 2 +- platform-api/src/internal/handler/api.go | 14 ++++-- platform-api/src/internal/service/api.go | 14 +++--- .../src/internal/service/gateway_test.go | 50 ++++++++++++++++++- platform-api/src/internal/utils/api.go | 4 ++ platform-api/src/resources/openapi.yaml | 45 +++++++++++------ 6 files changed, 101 insertions(+), 28 deletions(-) diff --git a/platform-api/src/internal/dto/openapi.go b/platform-api/src/internal/dto/openapi.go index bc6db050d..34064f43a 100644 --- a/platform-api/src/internal/dto/openapi.go +++ b/platform-api/src/internal/dto/openapi.go @@ -40,5 +40,5 @@ type OpenAPIValidationResponse struct { type ImportOpenAPIRequest struct { URL string `form:"url"` // Optional: URL to fetch OpenAPI definition Definition *multipart.FileHeader `form:"definition"` // Optional: Uploaded OpenAPI file (JSON/YAML) - API API `json:"api"` // API details for the imported definition + API API `form:"api"` // API details for the imported definition } diff --git a/platform-api/src/internal/handler/api.go b/platform-api/src/internal/handler/api.go index dee75df0d..3aedaa20a 100644 --- a/platform-api/src/internal/handler/api.go +++ b/platform-api/src/internal/handler/api.go @@ -929,19 +929,25 @@ func (h *APIHandler) ImportOpenAPI(c *gin.Context) { return } // Handle OpenAPI-specific errors - if strings.Contains(err.Error(), "failed to fetch OpenAPI definition") { + if strings.Contains(err.Error(), "failed to fetch OpenAPI from URL") { c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Failed to fetch OpenAPI definition from URL")) return } - if strings.Contains(err.Error(), "failed to validate OpenAPI definition") { + if strings.Contains(err.Error(), "failed to open OpenAPI definition file") || + strings.Contains(err.Error(), "failed to read OpenAPI definition file") { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Failed to fetch OpenAPI definition from file")) + return + } + if strings.Contains(err.Error(), "failed to validate and parse OpenAPI definition") { c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", "Invalid OpenAPI definition")) return } - if strings.Contains(err.Error(), "failed to parse OpenAPI definition") { + if strings.Contains(err.Error(), "failed to merge API details") { c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", - "Failed to parse OpenAPI definition")) + "Failed to create API from OpenAPI definition: incompatible details")) return } diff --git a/platform-api/src/internal/service/api.go b/platform-api/src/internal/service/api.go index 76c880f0a..fda628962 100644 --- a/platform-api/src/internal/service/api.go +++ b/platform-api/src/internal/service/api.go @@ -1395,14 +1395,14 @@ func (s *APIService) ImportFromOpenAPI(req *dto.ImportOpenAPIRequest, orgId stri if req.Definition != nil { file, err := req.Definition.Open() if err != nil { - errorList = append(errorList, fmt.Sprintf("failed to open definition file: %s", err.Error())) + errorList = append(errorList, fmt.Sprintf("failed to open OpenAPI definition file: %s", err.Error())) return nil, fmt.Errorf(strings.Join(errorList, "; ")) } defer file.Close() content, err = io.ReadAll(file) if err != nil { - errorList = append(errorList, fmt.Sprintf("failed to read definition file: %s", err.Error())) + errorList = append(errorList, fmt.Sprintf("failed to read OpenAPI definition file: %s", err.Error())) return nil, fmt.Errorf(strings.Join(errorList, "; ")) } } @@ -1416,11 +1416,14 @@ func (s *APIService) ImportFromOpenAPI(req *dto.ImportOpenAPIRequest, orgId stri // Validate and parse the OpenAPI definition apiDetails, err := s.apiUtil.ValidateAndParseOpenAPI(content) if err != nil { - return nil, fmt.Errorf("failed to validate OpenAPI definition: %w", err) + return nil, fmt.Errorf("failed to validate and parse OpenAPI definition: %w", err) } // Merge provided API details with extracted details from OpenAPI mergedAPI := s.apiUtil.MergeAPIDetails(&req.API, apiDetails) + if mergedAPI == nil { + return nil, errors.New("failed to merge API details") + } // Create API using existing CreateAPI logic createReq := &CreateAPIRequest{ @@ -1442,11 +1445,6 @@ func (s *APIService) ImportFromOpenAPI(req *dto.ImportOpenAPIRequest, orgId stri Operations: mergedAPI.Operations, } - // Validate the merged API details - if err := s.validateCreateAPIRequest(createReq); err != nil { - return nil, err - } - // Create the API return s.CreateAPI(createReq, orgId) } diff --git a/platform-api/src/internal/service/gateway_test.go b/platform-api/src/internal/service/gateway_test.go index df1fd4b42..f300fc489 100644 --- a/platform-api/src/internal/service/gateway_test.go +++ b/platform-api/src/internal/service/gateway_test.go @@ -156,7 +156,7 @@ func TestValidateGatewayInput(t *testing.T) { errContains: "must not exceed 128 characters", }, { - name: "empty display name", + name: "empty vhost", orgID: "123e4567-e89b-12d3-a456-426614174000", gatewayName: "prod-gateway-01", displayName: "Production Gateway 01", @@ -174,6 +174,54 @@ func TestValidateGatewayInput(t *testing.T) { functionalityType: constants.GatewayFunctionalityTypeRegular, wantErr: false, }, + { + name: "empty functionality type", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: "", + wantErr: true, + errContains: "functionality type is required", + }, + { + name: "whitespace-only functionality type", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: " ", + wantErr: true, + errContains: "functionality type is required", + }, + { + name: "invalid functionality type", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "prod-gateway-01", + displayName: "Production Gateway 01", + vhost: "api.example.com", + functionalityType: "invalid-type", + wantErr: true, + errContains: "invalid functionality type", + }, + { + name: "valid functionality type - ai", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "ai-gateway-01", + displayName: "AI Gateway 01", + vhost: "ai.example.com", + functionalityType: "ai", + wantErr: false, + }, + { + name: "valid functionality type - event", + orgID: "123e4567-e89b-12d3-a456-426614174000", + gatewayName: "event-gateway-01", + displayName: "Event Gateway 01", + vhost: "events.example.com", + functionalityType: "event", + wantErr: false, + }, } for _, tt := range tests { diff --git a/platform-api/src/internal/utils/api.go b/platform-api/src/internal/utils/api.go index 9a8ad2471..d05bff567 100644 --- a/platform-api/src/internal/utils/api.go +++ b/platform-api/src/internal/utils/api.go @@ -1452,6 +1452,10 @@ func (u *APIUtil) ValidateAndParseOpenAPI(content []byte) (*dto.API, error) { // MergeAPIDetails merges user-provided API details with extracted OpenAPI details // User-provided details take precedence over extracted details func (u *APIUtil) MergeAPIDetails(userAPI *dto.API, extractedAPI *dto.API) *dto.API { + if userAPI == nil || extractedAPI == nil { + return nil + } + merged := &dto.API{} // Required fields from user input (these must be provided) diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index 8072c582f..e62f6f9a0 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -310,9 +310,19 @@ paths: description: OpenAPI import details required: true content: - application/json: + multipart/form-data: schema: $ref: '#/components/schemas/ImportOpenAPIRequest' + encoding: + url: + contentType: text/plain + style: form + definition: + contentType: application/octet-stream, application/json, application/yaml, text/yaml + style: form + api: + contentType: application/json + style: form responses: '201': description: OpenAPI imported successfully @@ -2617,25 +2627,32 @@ components: type: string format: uri description: | - Form field: URL to fetch the OpenAPI definition from. - Provide this as a form field when importing from a URL. + Form field containing URL to fetch the OpenAPI definition from. example: "https://petstore3.swagger.io/api/v3/openapi.json" definition: type: string format: binary description: | - Form field: OpenAPI definition file upload (YAML or JSON). - Provide this as a file upload field when importing from a local file. + Form field for OpenAPI definition file upload (YAML or JSON). + This should be provided as a file upload in the multipart form. api: - allOf: - - $ref: '#/components/schemas/API' - - type: object - description: API details for the imported project - required: - - name - - context - - version - - projectId + type: string + description: | + Form field containing JSON-encoded API details for the imported OpenAPI. + This must be a JSON string containing the API configuration. + Required fields within the JSON: name, context, version, projectId. + example: | + { + "name": "PetStore API", + "displayName": "PetStore API v3", + "description": "This is a sample Pet Store Server", + "context": "/petstore", + "version": "1.0.0", + "provider": "PetStore Inc", + "projectId": "550e8400-e29b-41d4-a716-446655440000", + "type": "HTTP", + "transport": ["http", "https"] + } anyOf: - required: [ url ] - required: [ definition ]