diff --git a/platform-api/src/go.mod b/platform-api/src/go.mod index 80167e35..7b9351fb 100644 --- a/platform-api/src/go.mod +++ b/platform-api/src/go.mod @@ -1,6 +1,6 @@ module platform-api/src -go 1.24 +go 1.24.7 require ( github.com/gin-contrib/cors v1.7.6 @@ -10,10 +10,13 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/kelseyhightower/envconfig v1.4.0 github.com/mattn/go-sqlite3 v1.14.32 + github.com/pb33f/libopenapi v0.28.2 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect @@ -26,24 +29,27 @@ require ( github.com/goccy/go-yaml v1.18.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pb33f/jsonpath v0.1.2 // indirect + github.com/pb33f/ordered-map/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/platform-api/src/go.sum b/platform-api/src/go.sum index 5be960bb..ccd0dc91 100644 --- a/platform-api/src/go.sum +++ b/platform-api/src/go.sum @@ -1,10 +1,13 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -43,8 +46,8 @@ github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dv github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -58,6 +61,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pb33f/jsonpath v0.1.2 h1:PlqXjEyecMqoYJupLxYeClCGWEpAFnh4pmzgspbXDPI= +github.com/pb33f/jsonpath v0.1.2/go.mod h1:TtKnUnfqZm48q7a56DxB3WtL3ipkVtukMKGKxaR/uXU= +github.com/pb33f/libopenapi v0.28.2 h1:AXVCE8DWzytXu0jv0Z+cXVopnO/bXU1oWvgA9qiRWgw= +github.com/pb33f/libopenapi v0.28.2/go.mod h1:mHMHA3ZKSZDTInNAuUtqkHlKLIjPm2HN1vgsGR57afc= +github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ= +github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -66,8 +75,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -83,23 +92,25 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index b5acdc6f..d69d360a 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -83,4 +83,12 @@ var ( ErrAPIPublicationNotFound = errors.New("api publication not found") ErrAPIPublicationInProgress = errors.New("api publication is currently in progress") ErrAPIAlreadyPublished = errors.New("api is already published to devportal") + + // API Project Import errors + ErrAPIProjectNotFound = errors.New("api project not found") + ErrMalformedAPIProject = errors.New("malformed api project") + ErrInvalidAPIProject = errors.New("invalid api project") + ErrConfigFileNotFound = errors.New("API Project config file not found") + ErrOpenAPIFileNotFound = errors.New("OpenAPI definition file not found") + ErrWSO2ArtifactNotFound = errors.New("WSO2 API artifact not found") ) diff --git a/platform-api/src/internal/dto/api-project.go b/platform-api/src/internal/dto/api-project.go new file mode 100644 index 00000000..f56736ef --- /dev/null +++ b/platform-api/src/internal/dto/api-project.go @@ -0,0 +1,67 @@ +/* + * 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 + +// ImportAPIProjectRequest represents the request payload for importing an API project from Git +type ImportAPIProjectRequest struct { + RepoURL string `json:"repoUrl" binding:"required"` + Provider string `json:"provider,omitempty"` // Optional: "github", "gitlab", "bitbucket", etc. + Branch string `json:"branch" binding:"required"` + Path string `json:"path" binding:"required"` + API API `json:"api" binding:"required"` +} + +// APIProjectConfig represents the structure of config.yaml file in .api-platform directory +type APIProjectConfig struct { + Version string `yaml:"version"` + APIs []APIConfigEntry `yaml:"apis"` + SpectralRulesets []SpectralRuleset `yaml:"spectralRulesets,omitempty"` +} + +// APIConfigEntry represents an API entry in the config.yaml +type APIConfigEntry struct { + OpenAPI string `yaml:"openapi"` + WSO2Artifact string `yaml:"wso2Artifact"` + Documentation string `yaml:"documentation,omitempty"` + Tests string `yaml:"tests,omitempty"` +} + +// SpectralRuleset represents a spectral ruleset configuration +type SpectralRuleset struct { + Name string `yaml:"name"` + SourceFolder string `yaml:"sourceFolder"` + FileName string `yaml:"fileName"` + RulesetContentPath string `yaml:"rulesetContentPath"` +} + +// ValidateAPIProjectRequest represents the request payload for validating an API project from Git +type ValidateAPIProjectRequest struct { + RepoURL string `json:"repoUrl" binding:"required"` + Provider string `json:"provider,omitempty"` // Optional: "github", "gitlab", "bitbucket", etc. + Branch string `json:"branch" binding:"required"` + Path string `json:"path" binding:"required"` +} + +// APIProjectValidationResponse represents the response for API project validation +type APIProjectValidationResponse struct { + IsAPIProjectValid bool `json:"isAPIProjectValid"` + IsAPIConfigValid bool `json:"isAPIConfigValid"` + IsAPIDefinitionValid bool `json:"isAPIDefinitionValid"` + Errors []string `json:"errors,omitempty"` + API *API `json:"api,omitempty"` +} diff --git a/platform-api/src/internal/dto/api.go b/platform-api/src/internal/dto/api.go index c5788064..4b53dc65 100644 --- a/platform-api/src/internal/dto/api.go +++ b/platform-api/src/internal/dto/api.go @@ -181,9 +181,9 @@ type APIRevisionDeployment struct { // APIDeploymentYAML represents the API deployment YAML structure type APIDeploymentYAML struct { - Kind string `yaml:"kind"` - Version string `yaml:"version"` - Data APIYAMLData2 `yaml:"data"` + Kind string `yaml:"kind" binding:"required"` + Version string `yaml:"version" binding:"required"` + Data APIYAMLData2 `yaml:"data" binding:"required"` } // APIYAMLData2 represents a basic data section of the API deployment YAML diff --git a/platform-api/src/internal/handler/api.go b/platform-api/src/internal/handler/api.go index 3c89bfdd..06ad87e6 100644 --- a/platform-api/src/internal/handler/api.go +++ b/platform-api/src/internal/handler/api.go @@ -610,6 +610,164 @@ func (h *APIHandler) GetAPIPublications(c *gin.Context) { c.JSON(http.StatusOK, response) } +// ImportAPIProject handles POST /api/v1/import/api-project +func (h *APIHandler) ImportAPIProject(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 + } + + var req dto.ImportAPIProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + return + } + + // Validate required fields + if req.RepoURL == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Repository URL is required")) + return + } + if req.Branch == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Branch is required")) + return + } + if req.Path == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Path is required")) + 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 + } + + // Create Git service + gitService := service.NewGitService() + + // Import API project + api, err := h.apiService.ImportAPIProject(&req, orgId, gitService) + if err != nil { + if errors.Is(err, constants.ErrAPIProjectNotFound) { + c.JSON(http.StatusNotFound, utils.NewErrorResponse(404, "API Project Not Found", + "API project not found: .api-platform directory not found")) + return + } + if errors.Is(err, constants.ErrMalformedAPIProject) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Malformed API Project", + "Malformed API project: config.yaml is missing or invalid")) + return + } + if errors.Is(err, constants.ErrInvalidAPIProject) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Invalid API Project", + "Invalid API project: referenced files not found")) + return + } + if errors.Is(err, constants.ErrConfigFileNotFound) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Config File Not Found", + "config.yaml file not found in .api-platform directory")) + return + } + if errors.Is(err, constants.ErrOpenAPIFileNotFound) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "OpenAPI File Not Found", + "OpenAPI file not found")) + return + } + if errors.Is(err, constants.ErrWSO2ArtifactNotFound) { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "WSO2 Artifact Not Found", + "WSO2 artifact file not found")) + return + } + 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 + } + + log.Printf("Failed to import API project: %v", err) + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to import API project")) + return + } + + c.JSON(http.StatusCreated, api) +} + +// ValidateAPIProject handles POST /validate/api-project +func (h *APIHandler) ValidateAPIProject(c *gin.Context) { + var req dto.ValidateAPIProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", err.Error())) + return + } + + // Validate required fields + if req.RepoURL == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Repository URL is required")) + return + } + if req.Branch == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Branch is required")) + return + } + if req.Path == "" { + c.JSON(http.StatusBadRequest, utils.NewErrorResponse(400, "Bad Request", + "Path is required")) + return + } + + // Create Git service + gitService := service.NewGitService() + + // Validate API project + response, err := h.apiService.ValidateAndRetrieveAPIProject(&req, gitService) + if err != nil { + c.JSON(http.StatusInternalServerError, utils.NewErrorResponse(500, "Internal Server Error", + "Failed to validate API project")) + 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 @@ -627,4 +785,12 @@ func (h *APIHandler) RegisterRoutes(r *gin.Engine) { apiGroup.POST("/:apiId/devportals/unpublish", h.UnpublishFromDevPortal) apiGroup.GET("/:apiId/publications", h.GetAPIPublications) } + importGroup := r.Group("/api/v1/import") + { + importGroup.POST("/api-project", h.ImportAPIProject) + } + validateGroup := r.Group("/api/v1/validate") + { + validateGroup.POST("/api-project", h.ValidateAPIProject) + } } diff --git a/platform-api/src/internal/service/api.go b/platform-api/src/internal/service/api.go index 288e017c..dce0f337 100644 --- a/platform-api/src/internal/service/api.go +++ b/platform-api/src/internal/service/api.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "log" + pathpkg "path" "platform-api/src/internal/dto" "platform-api/src/internal/model" "platform-api/src/internal/repository" @@ -32,6 +33,7 @@ import ( "platform-api/src/internal/constants" "github.com/google/uuid" + "gopkg.in/yaml.v3" ) // APIService handles business logic for API operations @@ -161,6 +163,9 @@ func (s *APIService) CreateAPI(req *CreateAPIRequest, orgId string) (*dto.API, e return nil, fmt.Errorf("failed to create api: %w", err) } + api.CreatedAt = apiModel.CreatedAt + api.UpdatedAt = apiModel.UpdatedAt + // Associate backend services with the API for i, backendServiceUUID := range backendServiceIdList { isDefault := i == 0 // First backend service is default @@ -944,6 +949,203 @@ func (s *APIService) generateDefaultOperations() []dto.Operation { } } +// ImportAPIProject imports an API project from a Git repository +func (s *APIService) ImportAPIProject(req *dto.ImportAPIProjectRequest, orgId string, gitService GitService) (*dto.API, error) { + // 1. Validate if there is a .api-platform directory with config.yaml + config, err := gitService.ValidateAPIProject(req.RepoURL, req.Branch, req.Path) + if err != nil { + if strings.Contains(err.Error(), "api project not found") { + return nil, constants.ErrAPIProjectNotFound + } + if strings.Contains(err.Error(), "malformed api project") { + return nil, constants.ErrMalformedAPIProject + } + if strings.Contains(err.Error(), "invalid api project") { + return nil, constants.ErrInvalidAPIProject + } + return nil, err + } + + // For now, we'll process the first API in the config (can be extended later for multiple APIs) + if len(config.APIs) == 0 { + return nil, constants.ErrMalformedAPIProject + } + + apiConfig := config.APIs[0] + + // 5. Fetch the WSO2 artifact file content + wso2ArtifactClean := pathpkg.Clean(apiConfig.WSO2Artifact) + wso2ArtifactPath := pathpkg.Join(req.Path, wso2ArtifactClean) + artifactData, err := gitService.FetchWSO2Artifact(req.RepoURL, req.Branch, wso2ArtifactPath) + if err != nil { + return nil, constants.ErrWSO2ArtifactNotFound + } + + // 6. Create API with details from WSO2 artifact, overwritten by request details + apiData := s.mergeAPIData(&artifactData.Data, &req.API) + + // 7. Create API using the existing CreateAPI flow + createReq := &CreateAPIRequest{ + Name: apiData.Name, + DisplayName: apiData.DisplayName, + Description: apiData.Description, + Context: apiData.Context, + Version: apiData.Version, + Provider: apiData.Provider, + ProjectID: apiData.ProjectID, + LifeCycleStatus: apiData.LifeCycleStatus, + HasThumbnail: apiData.HasThumbnail, + IsDefaultVersion: apiData.IsDefaultVersion, + IsRevision: apiData.IsRevision, + RevisionedAPIID: apiData.RevisionedAPIID, + RevisionID: apiData.RevisionID, + Type: apiData.Type, + Transport: apiData.Transport, + MTLS: apiData.MTLS, + Security: apiData.Security, + CORS: apiData.CORS, + BackendServices: apiData.BackendServices, + APIRateLimiting: apiData.APIRateLimiting, + Operations: apiData.Operations, + } + + return s.CreateAPI(createReq, orgId) +} + +// mergeAPIData merges WSO2 artifact data with user-provided API data (user data takes precedence) +func (s *APIService) mergeAPIData(artifact *dto.APIYAMLData2, userAPIData *dto.API) *dto.API { + apiDTO := s.apiUtil.APIYAMLData2ToDTO(artifact) + + // Overwrite with user-provided data (if not empty) + if userAPIData.Name != "" { + apiDTO.Name = userAPIData.Name + } + if userAPIData.DisplayName != "" { + apiDTO.DisplayName = userAPIData.DisplayName + } + if userAPIData.Description != "" { + apiDTO.Description = userAPIData.Description + } + if userAPIData.Context != "" { + apiDTO.Context = userAPIData.Context + } + if userAPIData.Version != "" { + apiDTO.Version = userAPIData.Version + } + if userAPIData.Provider != "" { + apiDTO.Provider = userAPIData.Provider + } + if userAPIData.ProjectID != "" { + apiDTO.ProjectID = userAPIData.ProjectID + } + if userAPIData.LifeCycleStatus != "" { + apiDTO.LifeCycleStatus = userAPIData.LifeCycleStatus + } + if userAPIData.Type != "" { + apiDTO.Type = userAPIData.Type + } + if len(userAPIData.Transport) > 0 { + apiDTO.Transport = userAPIData.Transport + } + if userAPIData.BackendServices != nil && len(userAPIData.BackendServices) > 0 { + apiDTO.BackendServices = userAPIData.BackendServices + } + + // Handle boolean fields + apiDTO.HasThumbnail = userAPIData.HasThumbnail + apiDTO.IsDefaultVersion = userAPIData.IsDefaultVersion + apiDTO.IsRevision = userAPIData.IsRevision + + if userAPIData.RevisionedAPIID != "" { + apiDTO.RevisionedAPIID = userAPIData.RevisionedAPIID + } + if userAPIData.RevisionID != 0 { + apiDTO.RevisionID = userAPIData.RevisionID + } + + return apiDTO +} + +// ValidateAndRetrieveAPIProject validates an API project from Git repository with comprehensive checks +func (s *APIService) ValidateAndRetrieveAPIProject(req *dto.ValidateAPIProjectRequest, + gitService GitService) (*dto.APIProjectValidationResponse, error) { + response := &dto.APIProjectValidationResponse{ + IsAPIProjectValid: false, + IsAPIConfigValid: false, + IsAPIDefinitionValid: false, + Errors: []string{}, + } + + // Step 1: Check if .api-platform directory exists and validate config + config, err := gitService.ValidateAPIProject(req.RepoURL, req.Branch, req.Path) + if err != nil { + response.Errors = append(response.Errors, err.Error()) + return response, nil + } + + // Process the first API entry (assuming single API per project for now) + apiEntry := config.APIs[0] + + // Step 3: Fetch OpenAPI definition + openAPIClean := pathpkg.Clean(apiEntry.OpenAPI) + openAPIPath := pathpkg.Join(req.Path, openAPIClean) + openAPIContent, err := gitService.FetchFileContent(req.RepoURL, req.Branch, openAPIPath) + if err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("failed to fetch OpenAPI file: %s", err.Error())) + return response, nil + } + + // Basic OpenAPI validation (check if it's valid YAML/JSON with required fields) + if err := s.apiUtil.ValidateOpenAPIDefinition(openAPIContent); err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("invalid OpenAPI definition: %s", err.Error())) + return response, nil + } + + response.IsAPIDefinitionValid = true + + // Step 4: Fetch WSO2 artifact (api.yaml) + wso2ArtifactClean := pathpkg.Clean(apiEntry.WSO2Artifact) + wso2ArtifactPath := pathpkg.Join(req.Path, wso2ArtifactClean) + wso2ArtifactContent, err := gitService.FetchFileContent(req.RepoURL, req.Branch, wso2ArtifactPath) + if err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("failed to fetch WSO2 artifact file: %s", err.Error())) + return response, nil + } + + var wso2Artifact dto.APIDeploymentYAML + if err := yaml.Unmarshal(wso2ArtifactContent, &wso2Artifact); err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("invalid WSO2 artifact format: %s", err.Error())) + return response, nil + } + + // Step 5: Validate WSO2 artifact structure + if err := s.apiUtil.ValidateWSO2Artifact(&wso2Artifact); err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("invalid WSO2 artifact: %s", err.Error())) + return response, nil + } + + response.IsAPIConfigValid = true + + // Step 6: Check if OpenAPI and WSO2 artifact match (optional validation) + if err := s.apiUtil.ValidateAPIDefinitionConsistency(openAPIContent, &wso2Artifact); err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("API definitions mismatch: %s", err.Error())) + response.IsAPIProjectValid = false + return response, nil + } + + // Step 7: If all validations pass, convert to API DTO + api, err := s.apiUtil.ConvertAPIYAMLDataToDTO(&wso2Artifact) + if err != nil { + response.Errors = append(response.Errors, fmt.Sprintf("failed to convert API data: %s", err.Error())) + return response, nil + } + + response.API = api + response.IsAPIProjectValid = response.IsAPIConfigValid && response.IsAPIDefinitionValid + + return response, nil +} + // PublishAPIToDevPortal publishes an API to a specific DevPortal func (s *APIService) PublishAPIToDevPortal(apiID string, req *dto.PublishToDevPortalRequest, orgID string) (*dto.PublishToDevPortalResponse, error) { // Get the API diff --git a/platform-api/src/internal/service/git.go b/platform-api/src/internal/service/git.go index 2ec3a01a..0ecce567 100644 --- a/platform-api/src/internal/service/git.go +++ b/platform-api/src/internal/service/git.go @@ -19,13 +19,19 @@ package service import ( "fmt" + "gopkg.in/yaml.v3" + pathpkg "path" "platform-api/src/internal/dto" + "strings" ) type GitService interface { FetchRepoBranches(repoURL string) (*dto.GitRepoBranchesResponse, error) FetchRepoContent(repoURL, branch string) (*dto.GitRepoContentResponse, error) GetSupportedProviders() []string + FetchFileContent(repoURL, branch, path string) ([]byte, error) + ValidateAPIProject(repoURL, branch, path string) (*dto.APIProjectConfig, error) + FetchWSO2Artifact(repoURL, branch, path string) (*dto.APIDeploymentYAML, error) } type gitService struct { @@ -104,3 +110,102 @@ func (s *gitService) GetSupportedProviders() []string { } return providers } + +// FetchFileContent fetches the content of a specific file from a Git repository +func (s *gitService) FetchFileContent(repoURL, branch, path string) ([]byte, error) { + // Parse repository URL to determine provider and extract owner/repo + repoInfo, err := ParseRepositoryURL(repoURL) + if err != nil { + return nil, fmt.Errorf("invalid repository URL: %w", err) + } + + // Get the appropriate provider client + providerClient, exists := s.providers[repoInfo.Provider] + if !exists { + return nil, fmt.Errorf("unsupported Git provider: %s", repoInfo.Provider) + } + + normalizedPath := strings.TrimSpace(path) + normalizedPath = strings.TrimPrefix(normalizedPath, "/") + normalizedPath = strings.TrimPrefix(pathpkg.Clean("/"+normalizedPath), "/") + + // Use the provider-specific client to fetch file content + content, err := providerClient.FetchFileContent(repoInfo.Owner, repoInfo.Repo, branch, normalizedPath) + if err != nil { + return nil, err + } + + return content, nil +} + +// ValidateAPIProject validates an API project structure in a Git repository +func (s *gitService) ValidateAPIProject(repoURL, branch, path string) (*dto.APIProjectConfig, error) { + // 1. Check if .api-platform directory exists + configPath := pathpkg.Join(path, ".api-platform", "config.yaml") + configContent, err := s.FetchFileContent(repoURL, branch, configPath) + if err != nil { + return nil, fmt.Errorf("api project not found: .api-platform directory or config.yaml not found") + } + + // 2. Parse config.yaml + var config dto.APIProjectConfig + if err := yaml.Unmarshal(configContent, &config); err != nil { + return nil, fmt.Errorf("malformed api project: invalid config.yaml format") + } + + // 3. Validate config structure + if len(config.APIs) == 0 { + return nil, fmt.Errorf("malformed api project: no APIs defined in config.yaml") + } + + for _, api := range config.APIs { + if api.OpenAPI == "" || api.WSO2Artifact == "" { + return nil, fmt.Errorf("malformed api project: apis.openapi and apis.wso2Artifact fields are required") + } + + // Sanitize paths to prevent traversal attacks + openAPIClean := pathpkg.Clean(api.OpenAPI) + if strings.HasPrefix(openAPIClean, "..") || pathpkg.IsAbs(openAPIClean) { + return nil, fmt.Errorf("malformed api project: invalid openapi path: %s", api.OpenAPI) + } + wso2ArtifactClean := pathpkg.Clean(api.WSO2Artifact) + if strings.HasPrefix(wso2ArtifactClean, "..") || pathpkg.IsAbs(wso2ArtifactClean) { + return nil, fmt.Errorf("malformed api project: invalid wso2Artifact path: %s", api.WSO2Artifact) + } + + // 4. Check if the referenced files exist in the project path + openAPIPath := pathpkg.Join(path, openAPIClean) + _, err := s.FetchFileContent(repoURL, branch, openAPIPath) + if err != nil { + return nil, fmt.Errorf("invalid api project: openapi file not found: %s", api.OpenAPI) + } + + wso2ArtifactPath := pathpkg.Join(path, wso2ArtifactClean) + _, err = s.FetchFileContent(repoURL, branch, wso2ArtifactPath) + if err != nil { + return nil, fmt.Errorf("invalid api project: wso2 artifact file not found: %s", api.WSO2Artifact) + } + } + + return &config, nil +} + +// FetchWSO2Artifact fetches and parses the WSO2 artifact file from a Git repository +func (s *gitService) FetchWSO2Artifact(repoURL, branch, path string) (*dto.APIDeploymentYAML, error) { + content, err := s.FetchFileContent(repoURL, branch, path) + if err != nil { + return nil, fmt.Errorf("failed to fetch WSO2 artifact file at %s: %w", path, err) + } + + var artifact dto.APIDeploymentYAML + if err := yaml.Unmarshal(content, &artifact); err != nil { + return nil, fmt.Errorf("failed to parse WSO2 artifact file at %s: %w", path, err) + } + + // Validate required fields + if artifact.Kind == "" || artifact.Version == "" { + return nil, fmt.Errorf("malformed WSO2 artifact at %s: kind and version are required", path) + } + + return &artifact, nil +} diff --git a/platform-api/src/internal/service/git_bitbucket_client.go b/platform-api/src/internal/service/git_bitbucket_client.go index 8c0d1077..191f1d57 100644 --- a/platform-api/src/internal/service/git_bitbucket_client.go +++ b/platform-api/src/internal/service/git_bitbucket_client.go @@ -20,6 +20,7 @@ package service import ( "encoding/json" "fmt" + "io" "net/http" "platform-api/src/internal/dto" "regexp" @@ -115,6 +116,37 @@ func (c *BitbucketClient) FetchRepoBranches(owner, repo string) (*dto.GitRepoBra return response, nil } +// FetchFileContent fetches the content of a specific file from a Bitbucket repository +func (c *BitbucketClient) FetchFileContent(owner, repo, branch, path string) ([]byte, error) { + apiURL := fmt.Sprintf("https://api.bitbucket.org/2.0/repositories/%s/%s/src/%s/%s", owner, repo, branch, path) + + resp, err := c.httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch file content: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // Continue processing + case http.StatusNotFound: + return nil, fmt.Errorf("file not found: %s", path) + case http.StatusForbidden: + return nil, fmt.Errorf("access forbidden - repository may be private or rate limit exceeded") + case http.StatusUnauthorized: + return nil, fmt.Errorf("unauthorized access - repository may be private") + default: + return nil, fmt.Errorf("unexpected response status: %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read file content: %w", err) + } + + return content, nil +} + // FetchRepoContent fetches the contents of a Bitbucket repository branch func (c *BitbucketClient) FetchRepoContent(owner, repo, branch string) (*dto.GitRepoContentResponse, error) { // Build tree structure recursively diff --git a/platform-api/src/internal/service/git_github_client.go b/platform-api/src/internal/service/git_github_client.go index 9995887e..cd21a465 100644 --- a/platform-api/src/internal/service/git_github_client.go +++ b/platform-api/src/internal/service/git_github_client.go @@ -18,8 +18,10 @@ package service import ( + "encoding/base64" "encoding/json" "fmt" + "io" "log" "net/http" "platform-api/src/internal/dto" @@ -213,6 +215,72 @@ func (c *GitHubClient) FetchRepoContent(owner, repo, branch string) (*dto.GitRep return response, nil } +// FetchFileContent fetches the content of a specific file from a GitHub repository +func (c *GitHubClient) FetchFileContent(owner, repo, branch, path string) ([]byte, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", owner, repo, path, branch) + + resp, err := c.httpClient.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch file content: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // Continue processing + case http.StatusNotFound: + return nil, fmt.Errorf("file not found: %s", path) + case http.StatusForbidden: + return nil, fmt.Errorf("access forbidden - repository may be private or rate limit exceeded") + case http.StatusUnauthorized: + return nil, fmt.Errorf("unauthorized access - repository may be private") + default: + return nil, fmt.Errorf("unexpected response status: %d", resp.StatusCode) + } + + var fileResponse struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + DownloadURL string `json:"download_url"` + Size int `json:"size"` + } + + if err := json.NewDecoder(resp.Body).Decode(&fileResponse); err != nil { + return nil, fmt.Errorf("failed to parse file content response: %w", err) + } + + // If content is available and base64 encoded, decode it + if fileResponse.Content != "" && fileResponse.Encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(fileResponse.Content, "\n", "")) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 content: %w", err) + } + return decoded, nil + } + + // If content is not available (large files), use download_url + if fileResponse.DownloadURL != "" { + downloadResp, err := c.httpClient.Get(fileResponse.DownloadURL) + if err != nil { + return nil, fmt.Errorf("failed to download file from raw URL: %w", err) + } + defer downloadResp.Body.Close() + + if downloadResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download file, status: %d", downloadResp.StatusCode) + } + + content, err := io.ReadAll(downloadResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read downloaded content: %w", err) + } + return content, nil + } + + // If neither content nor download_url is available + return nil, fmt.Errorf("file content not available in GitHub API response") +} + // extractNextLink parses the GitHub Link header to find the next page URL func (c *GitHubClient) extractNextLink(linkHeader string) string { if linkHeader == "" { diff --git a/platform-api/src/internal/service/git_gitlab_client.go b/platform-api/src/internal/service/git_gitlab_client.go index 5b542b76..3b510bc9 100644 --- a/platform-api/src/internal/service/git_gitlab_client.go +++ b/platform-api/src/internal/service/git_gitlab_client.go @@ -257,3 +257,9 @@ func (c *GitLabClient) ValidateName(name string) bool { validPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`) return validPattern.MatchString(name) } + +// FetchFileContent fetches the content of a specific file from a GitLab repository +// TODO: Implement proper GitLab file content fetching +func (c *GitLabClient) FetchFileContent(owner, repo, branch, path string) ([]byte, error) { + return nil, fmt.Errorf("FetchFileContent not implemented for GitLab client - only GitHub is currently supported") +} diff --git a/platform-api/src/internal/service/git_provider.go b/platform-api/src/internal/service/git_provider.go index 3abcb02a..33454149 100644 --- a/platform-api/src/internal/service/git_provider.go +++ b/platform-api/src/internal/service/git_provider.go @@ -38,6 +38,7 @@ const ( type GitProviderClient interface { FetchRepoBranches(owner, repo string) (*dto.GitRepoBranchesResponse, error) FetchRepoContent(owner, repo, branch string) (*dto.GitRepoContentResponse, error) + FetchFileContent(owner, repo, branch, path string) ([]byte, error) ParseRepoURL(repoURL string) (owner, repo string, err error) ValidateName(name string) bool GetProvider() GitProvider diff --git a/platform-api/src/internal/utils/api.go b/platform-api/src/internal/utils/api.go index 65cb9992..37e48d06 100644 --- a/platform-api/src/internal/utils/api.go +++ b/platform-api/src/internal/utils/api.go @@ -22,6 +22,9 @@ import ( "fmt" "strings" + "github.com/pb33f/libopenapi" + v2high "github.com/pb33f/libopenapi/datamodel/high/v2" + v3high "github.com/pb33f/libopenapi/datamodel/high/v3" "gopkg.in/yaml.v3" "platform-api/src/internal/constants" "platform-api/src/internal/dto" @@ -840,3 +843,280 @@ func (u *APIUtil) GenerateOpenAPIDefinition(api *dto.API) ([]byte, error) { return apiDefinition, nil } + +// ConvertAPIYAMLDataToDTO converts APIDeploymentYAML to API DTO +func (u *APIUtil) ConvertAPIYAMLDataToDTO(artifact *dto.APIDeploymentYAML) (*dto.API, error) { + if artifact == nil { + return nil, fmt.Errorf("invalid artifact data") + } + + return u.APIYAMLData2ToDTO(&artifact.Data), nil +} + +// APIYAMLData2ToDTO converts APIYAMLData2 to API DTO +// +// This function maps the fields from APIYAMLData2 (simplified YAML structure) +// to the complete API DTO structure. Fields that don't exist in APIYAMLData2 +// are left with their zero values and should be populated by the caller. +// +// Parameters: +// - yamlData: The APIYAMLData2 source data +// +// Returns: +// - *dto.API: Converted API DTO with mapped fields +func (u *APIUtil) APIYAMLData2ToDTO(yamlData *dto.APIYAMLData2) *dto.API { + if yamlData == nil { + return nil + } + + // Convert upstreams to backend services if present + var backendServices []dto.BackendService + if len(yamlData.Upstreams) > 0 { + backendServices = make([]dto.BackendService, len(yamlData.Upstreams)) + for i, upstream := range yamlData.Upstreams { + backendServices[i] = dto.BackendService{ + IsDefault: i == 0, // First backend service is default + Endpoints: []dto.BackendEndpoint{ + { + URL: upstream.URL, + Description: upstream.Description, + Weight: upstream.Weight, + HealthCheck: upstream.HealthCheck, + MTLS: upstream.MTLS, + }, + }, + } + } + } + + // Convert operations if present + var operations []dto.Operation + if len(yamlData.Operations) > 0 { + operations = make([]dto.Operation, len(yamlData.Operations)) + for i, op := range yamlData.Operations { + operations[i] = dto.Operation{ + Name: fmt.Sprintf("Operation-%d", i+1), + Description: fmt.Sprintf("Operation for %s %s", op.Method, op.Path), + Request: &dto.OperationRequest{ + Method: op.Method, + Path: op.Path, + BackendServices: op.BackendServices, + Authentication: op.Authentication, + RequestPolicies: op.RequestPolicies, + ResponsePolicies: op.ResponsePolicies, + }, + } + } + } + + // Create and populate API DTO with available fields + api := &dto.API{ + ID: yamlData.Id, + Name: yamlData.Name, + DisplayName: yamlData.DisplayName, + Description: yamlData.Description, + Context: yamlData.Context, + Version: yamlData.Version, + Provider: yamlData.Provider, + BackendServices: backendServices, + Operations: operations, + + // Set reasonable defaults for required fields that aren't in APIYAMLData2 + LifeCycleStatus: "CREATED", + Type: "HTTP", + Transport: []string{"http", "https"}, + HasThumbnail: false, + IsDefaultVersion: false, + IsRevision: false, + RevisionID: 0, + + // Fields that need to be set by caller: + // - ProjectID (required) + // - OrganizationID (required) + // - CreatedAt, UpdatedAt (timestamps) + // - RevisionedAPIID (if applicable) + // - MTLS, Security, CORS, APIRateLimiting configs + } + + return api +} + +// Validation functions for OpenAPI specifications and WSO2 artifacts + +// ValidateOpenAPIDefinition performs comprehensive validation on OpenAPI content using libopenapi +func (u *APIUtil) ValidateOpenAPIDefinition(content []byte) error { + // Create a new document from the content + document, err := libopenapi.NewDocument(content) + if err != nil { + return fmt.Errorf("failed to parse document: %s", err.Error()) + } + + // Check the specification version + specInfo := document.GetSpecInfo() + if specInfo == nil { + return fmt.Errorf("unable to determine specification version") + } + + // Handle different specification versions based on version string + switch { + case specInfo.Version != "" && strings.HasPrefix(specInfo.Version, "3."): + return u.validateOpenAPI3Document(document) + case specInfo.Version != "" && strings.HasPrefix(specInfo.Version, "2."): + return u.validateSwagger2Document(document) + default: + // Try to determine from the document structure + return u.validateDocumentByStructure(document) + } +} + +// validateDocumentByStructure tries to validate by attempting to build both models +func (u *APIUtil) validateDocumentByStructure(document libopenapi.Document) error { + // Try OpenAPI 3.x first + v3Model, v3Errs := document.BuildV3Model() + if v3Errs == nil && v3Model != nil { + return u.validateOpenAPI3Model(v3Model) + } + + // Try Swagger 2.0 + v2Model, v2Errs := document.BuildV2Model() + if v2Errs == nil && v2Model != nil { + return u.validateSwagger2Model(v2Model) + } + + // 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 fmt.Errorf("document validation failed: %s", strings.Join(errorMessages, "; ")) +} + +// validateOpenAPI3Document validates OpenAPI 3.x documents using libopenapi +func (u *APIUtil) validateOpenAPI3Document(document libopenapi.Document) error { + // Build the OpenAPI 3.x model + docModel, err := document.BuildV3Model() + if err != nil { + return fmt.Errorf("OpenAPI 3.x model build error: %s", err.Error()) + } + + return u.validateOpenAPI3Model(docModel) +} + +// validateOpenAPI3Model validates an OpenAPI 3.x model +func (u *APIUtil) validateOpenAPI3Model(docModel *libopenapi.DocumentModel[v3high.Document]) error { + if docModel == nil { + return fmt.Errorf("invalid OpenAPI 3.x document model") + } + + // Get the OpenAPI document + doc := &docModel.Model + if doc.Info == nil { + return fmt.Errorf("missing required field: info") + } + + if doc.Info.Title == "" { + return fmt.Errorf("missing required field: info.title") + } + + if doc.Info.Version == "" { + return fmt.Errorf("missing required field: info.version") + } + + return nil +} + +// validateSwagger2Document validates Swagger 2.0 documents using libopenapi +func (u *APIUtil) validateSwagger2Document(document libopenapi.Document) error { + // Build the Swagger 2.0 model + docModel, err := document.BuildV2Model() + if err != nil { + return fmt.Errorf("Swagger 2.0 model build error: %s", err.Error()) + } + + return u.validateSwagger2Model(docModel) +} + +// validateSwagger2Model validates a Swagger 2.0 model +func (u *APIUtil) validateSwagger2Model(docModel *libopenapi.DocumentModel[v2high.Swagger]) error { + if docModel == nil { + return fmt.Errorf("invalid Swagger 2.0 document model") + } + + // Get the Swagger document + doc := &docModel.Model + if doc.Info == nil { + return fmt.Errorf("missing required field: info") + } + + if doc.Info.Title == "" { + return fmt.Errorf("missing required field: info.title") + } + + if doc.Info.Version == "" { + return fmt.Errorf("missing required field: info.version") + } + + if doc.Swagger == "" { + return fmt.Errorf("missing required field: swagger version") + } + + // Validate that it's a proper 2.0 version + if !strings.HasPrefix(doc.Swagger, "2.") { + return fmt.Errorf("invalid swagger version: %s, expected 2.x", doc.Swagger) + } + + return nil +} + +// ValidateWSO2Artifact validates the structure of WSO2 artifact +func (u *APIUtil) ValidateWSO2Artifact(artifact *dto.APIDeploymentYAML) error { + if artifact.Kind == "" { + return fmt.Errorf("invalid artifact: missing kind") + } + + if artifact.Version == "" { + return fmt.Errorf("invalid artifact: missing version") + } + + if artifact.Data.Name == "" { + return fmt.Errorf("missing API name") + } + + if artifact.Data.Context == "" { + return fmt.Errorf("missing API context") + } + + if artifact.Data.Version == "" { + return fmt.Errorf("missing API version") + } + + return nil +} + +// ValidateAPIDefinitionConsistency checks if OpenAPI and WSO2 artifact are consistent +func (u *APIUtil) ValidateAPIDefinitionConsistency(openAPIContent []byte, wso2Artifact *dto.APIDeploymentYAML) error { + var openAPIDoc map[string]interface{} + if err := yaml.Unmarshal(openAPIContent, &openAPIDoc); err != nil { + return fmt.Errorf("failed to parse OpenAPI document") + } + + // Extract info from OpenAPI + info, exists := openAPIDoc["info"].(map[string]interface{}) + if !exists { + return fmt.Errorf("missing info section in OpenAPI") + } + + // Check version consistency + if version, exists := info["version"].(string); exists { + if version != wso2Artifact.Data.Version { + return fmt.Errorf("version mismatch between OpenAPI (%s) and WSO2 artifact (%s)", + version, wso2Artifact.Data.Version) + } + } + + return nil +} diff --git a/platform-api/src/resources/openapi.yaml b/platform-api/src/resources/openapi.yaml index b1c837be..27051fbb 100644 --- a/platform-api/src/resources/openapi.yaml +++ b/platform-api/src/resources/openapi.yaml @@ -230,6 +230,76 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /validate/api-project: + post: + summary: Validate API project + description: | + Validates an API project into the platform from a provided Git Repository URL. + This endpoint checks the validity of the API project definitions and the overall validity of the API. + Returns the metadata of the API and the resources if the API project is valid. + operationId: ValidateAPIProject + tags: + - APIs + requestBody: + description: API project validation details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateAPIProjectRequest' + responses: + '201': + description: API project is valid + content: + application/json: + schema: + $ref: '#/components/schemas/APIProjectValidationResponse' + '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 + description: | + Imports an API project into the platform from a provided Git Repository URL. + An API is created from the imported API project and is associated with a specified project within the + organization in the JWT token. + operationId: ImportAPIProject + tags: + - APIs + requestBody: + description: API project import details + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImportAPIProjectRequest' + responses: + '201': + description: API project 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' + /apis: get: summary: Get all APIs for an organization @@ -2422,6 +2492,109 @@ components: description: Timestamp when API was unpublished example: "2025-10-29T21:08:59.834784232+05:30" + ValidateAPIProjectRequest: + type: object + required: + - repoUrl + - branch + - path + properties: + repoUrl: + type: string + format: uri + description: URL of the public Git repository + example: "https://github.com/example-org/sample-api" + provider: + type: string + enum: [ "github", "gitlab", "bitbucket" ] + description: Git provider (optional - will be auto-detected if not provided) + example: "github" + branch: + type: string + description: Branch of the repository to import from + example: "main" + path: + type: string + description: Path within the repository where the API project is located + example: "apis/inventory-api" + + APIProjectValidationResponse: + type: object + required: + - isAPIProjectValid + - isAPIConfigValid + - isAPIDefinitionValid + properties: + isAPIProjectValid: + type: boolean + description: Indicates if the API project structure is valid + example: true + isAPIConfigValid: + type: boolean + description: Indicates if the API configuration file is valid + example: true + 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 API definition file", "Invalid API configuration format" ] + api: + allOf: + - $ref: '#/components/schemas/API' + - type: object + description: Details for the validated API project + required: + - name + - displayName + - description + - context + - version + - backend-services + - projectId + - operations + + ImportAPIProjectRequest: + type: object + required: + - repoUrl + - branch + - path + - api + properties: + repoUrl: + type: string + format: uri + description: URL of the public Git repository + example: "https://github.com/example-org/sample-api" + provider: + type: string + enum: [ "github", "gitlab", "bitbucket" ] + description: Git provider (optional - will be auto-detected if not provided) + example: "github" + branch: + type: string + description: Branch of the repository to import from + example: "main" + path: + type: string + description: Path within the repository where the API project is located + example: "apis/inventory-api" + api: + allOf: + - $ref: '#/components/schemas/API' + - type: object + description: API details for the imported project + required: + - name + - context + - version + - projectId + responses: Unauthorized: description: Unauthorized. Authentication credentials are missing or invalid.