diff --git a/platform-api/src/internal/dto/openapi.go b/platform-api/src/internal/dto/openapi.go new file mode 100644 index 000000000..fdbf04386 --- /dev/null +++ b/platform-api/src/internal/dto/openapi.go @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dto + +import "mime/multipart" + +// ValidateOpenAPIRequest represents the multipart form request for OpenAPI validation from UI +// This handles file uploads from web UI where users can either: +// 1. Upload an OpenAPI definition file (JSON/YAML) +// 2. Provide a URL to fetch the OpenAPI definition +// 3. Provide both (service will prioritize file upload over URL) +type ValidateOpenAPIRequest struct { + URL string `form:"url"` // Optional: URL to fetch OpenAPI definition + Definition *multipart.FileHeader `form:"definition"` // Optional: Uploaded OpenAPI file (JSON/YAML) +} + +// OpenAPIValidationResponse represents the response for OpenAPI validation +type OpenAPIValidationResponse struct { + IsAPIDefinitionValid bool `json:"isAPIDefinitionValid"` + Errors []string `json:"errors,omitempty"` + API *API `json:"api,omitempty"` +} diff --git a/platform-api/src/internal/handler/api.go b/platform-api/src/internal/handler/api.go index 06ad87e63..24155b6a9 100644 --- a/platform-api/src/internal/handler/api.go +++ b/platform-api/src/internal/handler/api.go @@ -768,6 +768,48 @@ func (h *APIHandler) ValidateAPIProject(c *gin.Context) { c.JSON(http.StatusOK, response) } +// ValidateOpenAPI handles POST /validate/open-api +func (h *APIHandler) ValidateOpenAPI(c *gin.Context) { + // 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.ValidateOpenAPIRequest + + // 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 + } + + // Validate OpenAPI definition + response, err := h.apiService.ValidateOpenAPIDefinition(&req) + if err != nil { + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to validate OpenAPI definition")) + return + } + + // Return validation response (200 OK even if validation fails - errors are in the response body) + c.JSON(http.StatusOK, response) +} + // RegisterRoutes registers all API routes func (h *APIHandler) RegisterRoutes(r *gin.Engine) { // API routes @@ -792,5 +834,6 @@ func (h *APIHandler) RegisterRoutes(r *gin.Engine) { validateGroup := r.Group("/api/v1/validate") { validateGroup.POST("/api-project", h.ValidateAPIProject) + validateGroup.POST("/open-api", h.ValidateOpenAPI) } } diff --git a/platform-api/src/internal/service/api.go b/platform-api/src/internal/service/api.go index dce0f337c..46f76e48a 100644 --- a/platform-api/src/internal/service/api.go +++ b/platform-api/src/internal/service/api.go @@ -20,17 +20,18 @@ package service import ( "errors" "fmt" + "io" "log" pathpkg "path" - "platform-api/src/internal/dto" - "platform-api/src/internal/model" - "platform-api/src/internal/repository" - "platform-api/src/internal/utils" "regexp" "strings" "time" "platform-api/src/internal/constants" + "platform-api/src/internal/dto" + "platform-api/src/internal/model" + "platform-api/src/internal/repository" + "platform-api/src/internal/utils" "github.com/google/uuid" "gopkg.in/yaml.v3" @@ -1313,3 +1314,64 @@ func (s *APIService) convertToAPIDevPortalResponse(dpd *model.APIDevPortalWithDe return apiDevPortalResponse } + +// ValidateOpenAPIDefinition validates an OpenAPI definition from multipart form data +func (s *APIService) ValidateOpenAPIDefinition(req *dto.ValidateOpenAPIRequest) (*dto.OpenAPIValidationResponse, error) { + response := &dto.OpenAPIValidationResponse{ + IsAPIDefinitionValid: false, + Errors: []string{}, + } + + var content []byte + var err error + + // 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) + response.Errors = append(response.Errors, 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 { + response.Errors = append(response.Errors, fmt.Sprintf("failed to open definition file: %s", err.Error())) + return response, nil + } + defer file.Close() + + content, err = io.ReadAll(file) + if err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("failed to read definition file: %s", err.Error())) + return response, nil + } + } + + // If neither URL nor file is provided + if len(content) == 0 { + response.Errors = append(response.Errors, "either URL or definition file must be provided") + return response, nil + } + + // Validate the OpenAPI definition + if err := s.apiUtil.ValidateOpenAPIDefinition(content); err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("invalid OpenAPI definition: %s", err.Error())) + return response, nil + } + + // Parse API specification to extract metadata directly into API DTO using libopenapi + api, err := s.apiUtil.ParseAPIDefinition(content) + if err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("failed to parse API specification: %s", err.Error())) + return response, nil + } + + // Set the parsed API for response + response.IsAPIDefinitionValid = true + response.API = api + + return response, nil +} diff --git a/platform-api/src/internal/utils/api.go b/platform-api/src/internal/utils/api.go index 37e48d06b..5d0397629 100644 --- a/platform-api/src/internal/utils/api.go +++ b/platform-api/src/internal/utils/api.go @@ -20,7 +20,10 @@ package utils import ( "encoding/json" "fmt" + "io" + "net/http" "strings" + "time" "github.com/pb33f/libopenapi" v2high "github.com/pb33f/libopenapi/datamodel/high/v2" @@ -1120,3 +1123,312 @@ func (u *APIUtil) ValidateAPIDefinitionConsistency(openAPIContent []byte, wso2Ar return nil } + +// FetchOpenAPIFromURL fetches OpenAPI content from a URL +func (u *APIUtil) FetchOpenAPIFromURL(url string) ([]byte, error) { + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP error: %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return content, nil +} + +// ParseAPIDefinition parses OpenAPI 3.x or Swagger 2.0 content and extracts metadata directly into API DTO +func (u *APIUtil) ParseAPIDefinition(content []byte) (*dto.API, error) { + // Create a new document from the content using libopenapi + document, err := libopenapi.NewDocument(content) + if err != nil { + return nil, fmt.Errorf("failed to parse API definition: %w", err) + } + + // Check the specification version + specInfo := document.GetSpecInfo() + if specInfo == nil { + return nil, fmt.Errorf("unable to determine API specification version") + } + + // Handle different specification versions + switch { + case specInfo.Version != "" && strings.HasPrefix(specInfo.Version, "3."): + return u.parseOpenAPI3Document(document) + case specInfo.Version != "" && strings.HasPrefix(specInfo.Version, "2."): + return u.parseSwagger2Document(document) + default: + // Try to determine from document structure if version detection fails + return u.parseDocumentByStructure(document) + } +} + +// parseOpenAPI3Document parses OpenAPI 3.x documents using libopenapi and returns API DTO directly +func (u *APIUtil) parseOpenAPI3Document(document libopenapi.Document) (*dto.API, error) { + // Build the OpenAPI 3.x model + docModel, err := document.BuildV3Model() + if err != nil { + return nil, fmt.Errorf("failed to build OpenAPI 3.x model: %w", err) + } + + if docModel == nil { + return nil, fmt.Errorf("invalid OpenAPI 3.x document model") + } + + doc := &docModel.Model + if doc.Info == nil { + return nil, fmt.Errorf("missing required field: info") + } + + // Create API DTO directly + api := &dto.API{ + Name: doc.Info.Title, + DisplayName: doc.Info.Title, + Description: doc.Info.Description, + Version: doc.Info.Version, + Type: "HTTP", + Transport: []string{"http", "https"}, + } + + // Extract operations from paths + operations := u.extractOperationsFromV3Paths(doc.Paths) + api.Operations = operations + + // Extract backend services from servers + var backendServices []dto.BackendService + if doc.Servers != nil { + for _, server := range doc.Servers { + service := dto.BackendService{ + Name: server.Name, + Description: server.Description, + Endpoints: []dto.BackendEndpoint{ + { + URL: server.URL, + Weight: 100, + }, + }, + } + backendServices = append(backendServices, service) + } + } + + api.BackendServices = backendServices + + return api, nil +} + +// parseSwagger2Document parses Swagger 2.0 documents using libopenapi and returns API DTO directly +func (u *APIUtil) parseSwagger2Document(document libopenapi.Document) (*dto.API, error) { + // Build the Swagger 2.0 model + docModel, err := document.BuildV2Model() + if err != nil { + return nil, fmt.Errorf("failed to build Swagger 2.0 model: %w", err) + } + + if docModel == nil { + return nil, fmt.Errorf("invalid Swagger 2.0 document model") + } + + doc := &docModel.Model + if doc.Info == nil { + return nil, fmt.Errorf("missing required field: info") + } + + // Create API DTO directly + api := &dto.API{ + Name: doc.Info.Title, + DisplayName: doc.Info.Title, + Description: doc.Info.Description, + Version: doc.Info.Version, + Type: "HTTP", + Transport: []string{"http", "https"}, + } + + // Extract operations from paths + operations := u.extractOperationsFromV2Paths(doc.Paths) + api.Operations = operations + + // Convert Swagger 2.0 host/basePath/schemes to backend services + backendServices := u.convertSwagger2ToBackendServices(doc.Host, doc.BasePath, doc.Schemes) + + api.BackendServices = backendServices + + return api, nil +} + +// parseDocumentByStructure tries to parse by attempting to build both models +func (u *APIUtil) parseDocumentByStructure(document libopenapi.Document) (*dto.API, error) { + // Try OpenAPI 3.x first + v3Model, v3Errs := document.BuildV3Model() + if v3Errs == nil && v3Model != nil { + return u.parseOpenAPI3Document(document) + } + + // Try Swagger 2.0 + v2Model, v2Errs := document.BuildV2Model() + if v2Errs == nil && v2Model != nil { + return u.parseSwagger2Document(document) + } + + // Both failed, return error + var errorMessages []string + if v3Errs != nil { + errorMessages = append(errorMessages, "OpenAPI 3.x: "+v3Errs.Error()) + } + if v2Errs != nil { + errorMessages = append(errorMessages, "Swagger 2.0: "+v2Errs.Error()) + } + + return nil, fmt.Errorf("document parsing failed: %s", strings.Join(errorMessages, "; ")) +} + +// extractOperationsFromV3Paths extracts operations from OpenAPI 3.x paths +func (u *APIUtil) extractOperationsFromV3Paths(paths *v3high.Paths) []dto.Operation { + var operations []dto.Operation + + if paths == nil || paths.PathItems == nil { + return operations + } + + for pair := paths.PathItems.First(); pair != nil; pair = pair.Next() { + path := pair.Key() + pathItem := pair.Value() + if pathItem == nil { + continue + } + + // Extract operations for each HTTP method + methodOps := map[string]*v3high.Operation{ + "GET": pathItem.Get, + "POST": pathItem.Post, + "PUT": pathItem.Put, + "PATCH": pathItem.Patch, + "DELETE": pathItem.Delete, + "OPTIONS": pathItem.Options, + "HEAD": pathItem.Head, + "TRACE": pathItem.Trace, + } + + for method, operation := range methodOps { + if operation == nil { + continue + } + + op := dto.Operation{ + Name: operation.Summary, + Description: operation.Description, + Request: &dto.OperationRequest{ + Method: method, + Path: path, + Authentication: &dto.AuthenticationConfig{ + Required: false, + Scopes: []string{}, + }, + RequestPolicies: []dto.Policy{}, + ResponsePolicies: []dto.Policy{}, + }, + } + + operations = append(operations, op) + } + } + + return operations +} + +// extractOperationsFromV2Paths extracts operations from Swagger 2.0 paths +func (u *APIUtil) extractOperationsFromV2Paths(paths *v2high.Paths) []dto.Operation { + var operations []dto.Operation + + if paths == nil || paths.PathItems == nil { + return operations + } + + for pair := paths.PathItems.First(); pair != nil; pair = pair.Next() { + path := pair.Key() + pathItem := pair.Value() + + if pathItem == nil { + continue + } + + // Extract operations for each HTTP method + methodOps := map[string]*v2high.Operation{ + "GET": pathItem.Get, + "POST": pathItem.Post, + "PUT": pathItem.Put, + "PATCH": pathItem.Patch, + "DELETE": pathItem.Delete, + "OPTIONS": pathItem.Options, + "HEAD": pathItem.Head, + } + + for method, operation := range methodOps { + if operation == nil { + continue + } + + op := dto.Operation{ + Name: operation.Summary, + Description: operation.Description, + Request: &dto.OperationRequest{ + Method: method, + Path: path, + Authentication: &dto.AuthenticationConfig{ + Required: false, + Scopes: []string{}, + }, + RequestPolicies: []dto.Policy{}, + ResponsePolicies: []dto.Policy{}, + }, + } + + operations = append(operations, op) + } + } + + return operations +} + +// convertSwagger2ToBackendServices converts Swagger 2.0 host/basePath/schemes to backend services +func (u *APIUtil) convertSwagger2ToBackendServices(host, basePath string, schemes []string) []dto.BackendService { + var backendServices []dto.BackendService + + if host == "" { + return backendServices // No host specified, cannot create backend services + } + + if len(schemes) == 0 { + schemes = []string{"https"} // Default to HTTPS + } + + if basePath == "" { + basePath = "/" + } + + // Create backend services for each scheme + for _, scheme := range schemes { + url := fmt.Sprintf("%s://%s%s", scheme, host, basePath) + service := dto.BackendService{ + Endpoints: []dto.BackendEndpoint{ + { + URL: url, + Weight: 100, + }, + }, + } + backendServices = append(backendServices, service) + } + + return backendServices +} diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index 27051fbbc..e47350a2f 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -265,6 +265,37 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /validate/open-api: + post: + summary: Validate OpenAPI definition + description: | + Validates an OpenAPI definition provided either via URL or as a file upload. + Returns validation results including any errors found and extracted metadata + such as name, and operations if the definition is valid. + operationId: ValidateOpenAPI + tags: + - APIs + requestBody: + description: OpenAPI definition validation details + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/ValidateOpenAPIRequest' + responses: + '200': + description: OpenAPI definition validation completed + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAPIValidationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /import/api-project: post: summary: Import API project @@ -2518,6 +2549,25 @@ components: description: Path within the repository where the API project is located example: "apis/inventory-api" + ValidateOpenAPIRequest: + type: object + description: | + Request for validating OpenAPI definition. Can include either a URL, a file upload, + or both. If both are provided, the validation service can choose which to prioritize. + properties: + url: + type: string + format: uri + description: URL to fetch the OpenAPI definition from + example: "https://petstore3.swagger.io/api/v3/openapi.json" + definition: + type: string + format: binary + description: OpenAPI definition file upload (YAML or JSON) + anyOf: + - required: [url] + - required: [definition] + APIProjectValidationResponse: type: object required: @@ -2557,6 +2607,31 @@ components: - backend-services - projectId - operations + + OpenAPIValidationResponse: + type: object + required: + - isAPIDefinitionValid + properties: + isAPIDefinitionValid: + type: boolean + description: Indicates if the API definition file is valid + example: true + errors: + type: array + description: List of validation errors encountered + items: + type: string + example: [ "Missing OpenAPI definition", "Invalid OpenAPI definition" ] + api: + allOf: + - $ref: '#/components/schemas/API' + - type: object + description: Details for the validated API + required: + - name + - version + - operations ImportAPIProjectRequest: type: object