Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions platform-api/api/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 29 additions & 10 deletions platform-api/internal/dto/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,16 @@ type Vhosts struct {

// APIYAMLData represents a basic spec section of the API deployment YAML
type APIYAMLData struct {
DisplayName string `yaml:"displayName"`
Version string `yaml:"version"`
Context string `yaml:"context"`
SubscriptionPlans []string `yaml:"subscriptionPlans,omitempty"`
Vhosts *Vhosts `yaml:"vhosts,omitempty"`
Upstream *UpstreamYAML `yaml:"upstream,omitempty"`
Policies []Policy `yaml:"policies,omitempty"`
Operations []api.OperationRequest `yaml:"operations,omitempty"`
Channels []api.ChannelRequest `yaml:"channels,omitempty"`
DisplayName string `yaml:"displayName"`
Version string `yaml:"version"`
Context string `yaml:"context"`
SubscriptionPlans []string `yaml:"subscriptionPlans,omitempty"`
Vhosts *Vhosts `yaml:"vhosts,omitempty"`
Upstream *UpstreamYAML `yaml:"upstream,omitempty"`
UpstreamDefinitions []UpstreamDefinitionYAML `yaml:"upstreamDefinitions,omitempty"`
Policies []Policy `yaml:"policies,omitempty"`
Operations []api.OperationRequest `yaml:"operations,omitempty"`
Channels []api.ChannelRequest `yaml:"channels,omitempty"`
}

// UpstreamYAML represents the upstream configuration for API deployment YAML
Expand All @@ -160,10 +161,28 @@ type UpstreamTarget struct {
Ref string `yaml:"ref,omitempty"`
}

// UpstreamDefinitionYAML represents a reusable named upstream definition in the deployment YAML
type UpstreamDefinitionYAML struct {
Name string `yaml:"name"`
BasePath string `yaml:"basePath,omitempty"`
Timeout *UpstreamTimeoutYAML `yaml:"timeout,omitempty"`
Upstreams []UpstreamBackendYAML `yaml:"upstreams"`
}

// UpstreamBackendYAML represents a single backend target within an upstream definition.
type UpstreamBackendYAML struct {
URL string `yaml:"url"`
Weight *int `yaml:"weight,omitempty"`
}

// UpstreamTimeoutYAML represents the timeout configuration for an upstream definition.
type UpstreamTimeoutYAML struct {
Connect string `yaml:"connect,omitempty"`
}

// APIListResponse represents a paginated list of APIs (constitution-compliant)
type APIListResponse struct {
Count int `json:"count" yaml:"count"` // Number of items in current response
List []*API `json:"list" yaml:"list"` // Array of API objects
Pagination Pagination `json:"pagination" yaml:"pagination"` // Pagination metadata
}

187 changes: 187 additions & 0 deletions platform-api/internal/handler/api_upstream_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2026, 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 handler

import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/wso2/api-platform/platform-api/internal/database"
"github.com/wso2/api-platform/platform-api/internal/middleware"
"github.com/wso2/api-platform/platform-api/internal/repository"
"github.com/wso2/api-platform/platform-api/internal/service"
"github.com/wso2/api-platform/platform-api/internal/utils"

_ "github.com/mattn/go-sqlite3"
)

const restAPIBase = "/api/v0.9/rest-apis"
const restAPIOrg = "org-api-it-001"
const restAPIProject = "api-it-project"

// setupAPIHandlerEnv builds the real route -> handler -> service -> repository stack over an
// in-memory SQLite DB, seeded with a test organization and project, so tests can assert the
// HTTP status codes the REST API handler maps service errors to.
func setupAPIHandlerEnv(t *testing.T) (http.Handler, func()) {
t.Helper()

dbPath := filepath.Join(t.TempDir(), "test.db")
sqlDB, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if _, err := sqlDB.Exec("PRAGMA foreign_keys = ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
db := &database.DB{DB: sqlDB}

schema, err := os.ReadFile(filepath.Join("..", "database", "schema.sqlite.sql"))
if err != nil {
t.Fatalf("read schema: %v", err)
}
if _, err = db.Exec(string(schema)); err != nil {
t.Fatalf("apply schema: %v", err)
}
if _, err = db.Exec(`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at)
VALUES ('` + restAPIOrg + `', 'test-api-org', 'Test API Org', 'default', 'idp-ref', datetime('now'), datetime('now'))`); err != nil {
t.Fatalf("insert org: %v", err)
}
if _, err = db.Exec(`INSERT INTO projects (uuid, handle, display_name, organization_uuid, description, created_at, updated_at)
VALUES ('proj-api-it-001', '` + restAPIProject + `', 'API IT Project', '` + restAPIOrg + `', '', datetime('now'), datetime('now'))`); err != nil {
t.Fatalf("insert project: %v", err)
}

identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db))
apiService := service.NewAPIService(
repository.NewAPIRepo(db), repository.NewProjectRepo(db), repository.NewOrganizationRepo(db),
repository.NewGatewayRepo(db),
nil, // deploymentRepo: unused by the create/update validation paths under test
repository.NewSubscriptionPlanRepo(db), repository.NewCustomPolicyRepo(db),
nil, // gatewayEventsService: unused by create/update in these tests
&utils.APIUtil{}, slog.Default(), repository.NewAuditRepo(db), identityService,
)

h := NewAPIHandler(apiService, identityService, slog.Default())
mux := http.NewServeMux()
h.RegisterRoutes(mux)

return middleware.NewTestContextMiddleware(mux), func() { _ = sqlDB.Close() }
}

// doRESTAPIJSON issues an authenticated JSON request against r, scoped to restAPIOrg.
func doRESTAPIJSON(t *testing.T, r http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req, _ := http.NewRequest(method, path, bytes.NewBufferString(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Test-Org", restAPIOrg)
req.Header.Set("X-Test-User", "alice")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

// restAPIBody returns a create/update request body whose single operation references the
// given upstream definition name; the declared pool contains only "alt-backend".
func restAPIBody(opRef string) string {
return fmt.Sprintf(`{
"displayName": "Upstream IT API",
"version": "v1.0",
"context": "/upstream-it",
"projectId": %q,
"upstream": {"main": {"url": "http://main:8080"}},
"upstreamDefinitions": [{"name": "alt-backend", "upstreams": [{"url": "http://alt:9000"}]}],
"operations": [{"request": {"method": "GET", "path": "/x", "upstream": {"main": {"ref": %q}}}}]
}`, restAPIProject, opRef)
}

// TestAPIHandler_CreateInvalidUpstreamRefReturns400 pins the invalid-upstream mapping to
// HTTP 400 on create: an unresolved per-operation ref must surface as a validation
// failure, not an internal error.
func TestAPIHandler_CreateInvalidUpstreamRefReturns400(t *testing.T) {
r, cleanup := setupAPIHandlerEnv(t)
defer cleanup()

w := doRESTAPIJSON(t, r, http.MethodPost, restAPIBase, restAPIBody("missing"))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for unresolved per-op ref, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "not declared in upstreamDefinitions") {
t.Fatalf("expected unresolved-ref detail in response, got: %s", w.Body.String())
}
}

// TestAPIHandler_CreateInvalidBasePathReturns400 pins the same mapping for the
// upstreamDefinitions basePath contract (must start with '/' and must not end with '/').
func TestAPIHandler_CreateInvalidBasePathReturns400(t *testing.T) {
r, cleanup := setupAPIHandlerEnv(t)
defer cleanup()

body := fmt.Sprintf(`{
"displayName": "Upstream IT API",
"version": "v1.0",
"context": "/upstream-it",
"projectId": %q,
"upstream": {"main": {"url": "http://main:8080"}},
"upstreamDefinitions": [{"name": "alt-backend", "basePath": "/api/v2/", "upstreams": [{"url": "http://alt:9000"}]}],
"operations": [{"request": {"method": "GET", "path": "/x", "upstream": {"main": {"ref": "alt-backend"}}}}]
}`, restAPIProject)
w := doRESTAPIJSON(t, r, http.MethodPost, restAPIBase, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for basePath with trailing '/', got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "basePath") {
t.Fatalf("expected basePath detail in response, got: %s", w.Body.String())
}
}

// TestAPIHandler_UpdateInvalidUpstreamRefReturns400 pins the update-path mapping: a valid
// API is created first, then an update that dangles its per-operation ref must return 400.
func TestAPIHandler_UpdateInvalidUpstreamRefReturns400(t *testing.T) {
r, cleanup := setupAPIHandlerEnv(t)
defer cleanup()

created := doRESTAPIJSON(t, r, http.MethodPost, restAPIBase, restAPIBody("alt-backend"))
if created.Code != http.StatusCreated {
t.Fatalf("expected 201 for valid create, got %d: %s", created.Code, created.Body.String())
}
var createdAPI struct {
Id string `json:"id"`
}
if err := json.Unmarshal(created.Body.Bytes(), &createdAPI); err != nil || createdAPI.Id == "" {
t.Fatalf("could not read created API id: %v, body: %s", err, created.Body.String())
}

w := doRESTAPIJSON(t, r, http.MethodPut, restAPIBase+"/"+createdAPI.Id, restAPIBody("missing"))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for unresolved per-op ref on update, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "not declared in upstreamDefinitions") {
t.Fatalf("expected unresolved-ref detail in response, got: %s", w.Body.String())
}
}
24 changes: 13 additions & 11 deletions platform-api/internal/model/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ type API struct {
}

type RestAPIConfig struct {
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Context *string `json:"context,omitempty"`
Transport []string `json:"transport,omitempty"`
Upstream UpstreamConfig `json:"upstream,omitempty"`
Policies []Policy `json:"policies,omitempty"`
Operations []Operation `json:"operations,omitempty"`
SubscriptionPlans []string `json:"subscriptionPlans,omitempty"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Context *string `json:"context,omitempty"`
Transport []string `json:"transport,omitempty"`
Upstream UpstreamConfig `json:"upstream,omitempty"`
UpstreamDefinitions []ReusableUpstream `json:"upstreamDefinitions,omitempty"`
Policies []Policy `json:"policies,omitempty"`
Operations []Operation `json:"operations,omitempty"`
SubscriptionPlans []string `json:"subscriptionPlans,omitempty"`
}

// TableName returns the table name for the API model
Expand Down Expand Up @@ -84,9 +85,10 @@ type Channel struct {

// OperationRequest represents operation request details
type OperationRequest struct {
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Policies []Policy `json:"policies,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Policies []Policy `json:"policies,omitempty"`
Upstream *OperationUpstream `json:"upstream,omitempty"`
}

// ChannelRequest represents channel request details
Expand Down
Loading
Loading