Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
43fc09d
add api portal schema for postgres.
dushaniw Aug 12, 2026
d9aff4b
database schema.
dushaniw Aug 12, 2026
feb757a
add dao models for devportal.
dushaniw Aug 12, 2026
ef9ce1d
remove data_version. add dao code.
dushaniw Aug 12, 2026
ebe5325
add service and rest api implementation.
dushaniw Aug 13, 2026
88df843
add integration tests at handler level.
dushaniw Aug 13, 2026
f6c666e
Validate portal URL scheme on create and update
dushaniw Aug 17, 2026
0d7ca1d
Align API Portal description maxLength with database column
dushaniw Aug 17, 2026
48e92af
Accept workflowStatus on create and enforce url/status consistency
dushaniw Aug 17, 2026
11211d8
Split API Portal config into authConfig and metadata; encrypt secrets
dushaniw Aug 17, 2026
6435f86
Add outbound AuthProvider surface for API Portal callers
dushaniw Aug 17, 2026
53273f7
Clear stored authConfig when switching authType to local
dushaniw Aug 17, 2026
b1338ad
fix duplicate tag.
dushaniw Aug 18, 2026
86f1d2e
remove devportals tag.
dushaniw Aug 18, 2026
949ce62
Remove workflowStatus from the /api-portals wire surface
dushaniw Aug 25, 2026
f87bfc4
Rename workflow_status → status on the api_portals column and Go code
dushaniw Aug 25, 2026
aa1cdf8
Tighten the outbound token-endpoint call: shape check + no redirects
dushaniw Aug 25, 2026
78e2dc6
Enforce redirect refusal on caller-supplied *http.Client too
dushaniw Aug 25, 2026
cd6eac3
Merge branch 'main' of github.com:wso2/api-platform into feat/api-por…
dushaniw Aug 29, 2026
320f532
feat(api-portals): expose APIPortals capability on pdk.Deps
dushaniw Aug 29, 2026
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
365 changes: 346 additions & 19 deletions platform-api/api/generated.go

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions platform-api/internal/apperror/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ var (
ApplicationExists = def(CodeApplicationExists, http.StatusConflict, "An application with this name already exists.")
)

// API Portal entries.
var (
APIPortalNotFound = def(CodeAPIPortalNotFound, http.StatusNotFound, "The specified API Portal could not be found.")
APIPortalExists = def(CodeAPIPortalExists, http.StatusConflict, "An API Portal with this handle already exists in the organization.")
)

// Subscription entries.
var (
SubscriptionNotFound = def(CodeSubscriptionNotFound, http.StatusNotFound, "The specified subscription could not be found.")
Expand Down
6 changes: 6 additions & 0 deletions platform-api/internal/apperror/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ const (
CodeApplicationExists = "APPLICATION_EXISTS"
)

// API Portal domain codes.
const (
CodeAPIPortalNotFound = "API_PORTAL_NOT_FOUND"
CodeAPIPortalExists = "API_PORTAL_EXISTS"
)

// Subscription domain codes.
const (
CodeSubscriptionNotFound = "SUBSCRIPTION_NOT_FOUND"
Expand Down
48 changes: 48 additions & 0 deletions platform-api/internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,54 @@ var ValidGatewayTokenStatuses = map[string]bool{
GatewayTokenStatusRevoked: true,
}

// API Portal status constants. The column exists on api_portals for
// future extensibility but is not surfaced on the wire in the OSS offering:
// OSS registers a portal that's already running, so every OSS row is created
// as APIPortalStatusActive and never mutated by clients.
const (
APIPortalStatusPending = "pending"
APIPortalStatusActive = "active"
APIPortalStatusFailed = "failed"
)

// API Portal authConfig field-name constants used by Create/Update validation
// (required-field check) and by ClientCredentialsAuthProvider (payload build).
const (
APIPortalAuthConfigKeySTSTokenURL = "stsTokenUrl"
APIPortalAuthConfigKeyClientID = "clientId"
APIPortalAuthConfigKeyClientSecret = "clientSecret"
)

// APIPortalOAuth2RequiredAuthConfigKeys are the keys the oauth2 flow must
// supply in authConfig at Create time (or on Update when auth_type is being
// changed to oauth2). Order is stable so validation error messages list
// missing fields in a predictable sequence.
var APIPortalOAuth2RequiredAuthConfigKeys = []string{
APIPortalAuthConfigKeySTSTokenURL,
APIPortalAuthConfigKeyClientID,
APIPortalAuthConfigKeyClientSecret,
}

// APIPortalAuthConfigSensitiveKeys lists the authConfig keys whose values are
// treated as secrets: encrypted at rest via the platform vault and stripped
// from any response. Independent of auth_type — the set is small and the
// keys are the same shape across types.
var APIPortalAuthConfigSensitiveKeys = []string{
APIPortalAuthConfigKeyClientSecret,
}

// API Portal auth type constants
const (
APIPortalAuthTypeLocal = "local"
APIPortalAuthTypeOAuth2 = "oauth2"
)

// ValidAPIPortalAuthTypes holds accepted values for api_portals.auth_type
var ValidAPIPortalAuthTypes = map[string]bool{
APIPortalAuthTypeLocal: true,
APIPortalAuthTypeOAuth2: true,
}

// ValidArtifactKinds holds accepted values for artifacts.type for the core (non-plugin)
// artifact kinds. Plugin-owned kinds (e.g. WebSubApi, WebBrokerApi) are registered
// into the ArtifactTableRegistry during plugin Init.
Expand Down
21 changes: 21 additions & 0 deletions platform-api/internal/database/schema.postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,26 @@ CREATE TABLE IF NOT EXISTS mcp_proxies (
UNIQUE(organization_uuid, handle)
);

-- API Portals table (registration of an API Portal instance for an organization)
CREATE TABLE IF NOT EXISTS api_portals (
uuid VARCHAR(40) PRIMARY KEY,
organization_uuid VARCHAR(40) NOT NULL,
handle VARCHAR(40) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description VARCHAR(1023),
url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
auth_type VARCHAR(20) NOT NULL,
auth_configuration BYTEA NOT NULL,
metadata BYTEA NOT NULL,
created_by VARCHAR(200),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(200),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
UNIQUE (organization_uuid, handle)
);


CREATE TABLE IF NOT EXISTS api_keys (
uuid VARCHAR(40) PRIMARY KEY,
Expand Down Expand Up @@ -473,6 +493,7 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider
CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid);
CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid);
CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_applications_project_id ON applications(organization_uuid, project_uuid);
Expand Down
21 changes: 21 additions & 0 deletions platform-api/internal/database/schema.sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,26 @@ CREATE TABLE IF NOT EXISTS mcp_proxies (
UNIQUE(organization_uuid, handle)
);

-- API Portals table (registration of an API Portal instance for an organization)
CREATE TABLE IF NOT EXISTS api_portals (
uuid VARCHAR(40) PRIMARY KEY,
organization_uuid VARCHAR(40) NOT NULL,
handle VARCHAR(40) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description VARCHAR(1023),
url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
auth_type VARCHAR(20) NOT NULL,
auth_configuration BLOB NOT NULL,
metadata BLOB NOT NULL,
created_by VARCHAR(200),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(200),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
UNIQUE (organization_uuid, handle)
);

-- API Keys table (stores API keys for artifacts with hashes as JSON string)
CREATE TABLE IF NOT EXISTS api_keys (
uuid VARCHAR(40) PRIMARY KEY,
Expand Down Expand Up @@ -472,6 +492,7 @@ CREATE INDEX IF NOT EXISTS idx_llm_proxies_provider_uuid ON llm_proxies(provider
CREATE INDEX IF NOT EXISTS idx_llm_proxies_org ON llm_proxies(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_mcp_proxies_project ON mcp_proxies(project_uuid);
CREATE INDEX IF NOT EXISTS idx_mcp_proxies_org ON mcp_proxies(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_api_portals_org ON api_portals(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_api_keys_artifact ON api_keys(artifact_uuid);
CREATE INDEX IF NOT EXISTS idx_rest_apis_org ON rest_apis(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_applications_org ON applications(organization_uuid);
Expand Down
23 changes: 23 additions & 0 deletions platform-api/internal/database/schema.sqlserver.sql
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,27 @@ CREATE TABLE dbo.mcp_proxies (
UNIQUE(organization_uuid, handle)
);

-- API Portals table (registration of an API Portal instance for an organization)
IF OBJECT_ID(N'dbo.api_portals', N'U') IS NULL
CREATE TABLE dbo.api_portals (
uuid VARCHAR(40) PRIMARY KEY,
organization_uuid VARCHAR(40) NOT NULL,
handle VARCHAR(40) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description VARCHAR(1023),
url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
auth_type VARCHAR(20) NOT NULL,
auth_configuration VARBINARY(MAX) NOT NULL,
metadata VARBINARY(MAX) NOT NULL,
created_by VARCHAR(200),
created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
updated_by VARCHAR(200),
updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(),
FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE,
UNIQUE (organization_uuid, handle)
);

IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL
CREATE TABLE dbo.api_keys (
uuid VARCHAR(40) PRIMARY KEY,
Expand Down Expand Up @@ -554,6 +575,8 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_project'
CREATE INDEX idx_mcp_proxies_project ON dbo.mcp_proxies(project_uuid);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_mcp_proxies_org' AND object_id = OBJECT_ID(N'dbo.mcp_proxies'))
CREATE INDEX idx_mcp_proxies_org ON dbo.mcp_proxies(organization_uuid);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_portals_org' AND object_id = OBJECT_ID(N'dbo.api_portals'))
CREATE INDEX idx_api_portals_org ON dbo.api_portals(organization_uuid);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_artifact' AND object_id = OBJECT_ID(N'dbo.api_keys'))
CREATE INDEX idx_api_keys_artifact ON dbo.api_keys(artifact_uuid);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_keys_status' AND object_id = OBJECT_ID(N'dbo.api_keys'))
Expand Down
187 changes: 187 additions & 0 deletions platform-api/internal/handler/api_portal.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 (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"

"github.com/wso2/api-platform/platform-api/api"
"github.com/wso2/api-platform/platform-api/internal/apperror"
"github.com/wso2/api-platform/platform-api/internal/constants"
"github.com/wso2/api-platform/platform-api/internal/middleware"
"github.com/wso2/api-platform/platform-api/internal/router"
"github.com/wso2/api-platform/platform-api/internal/service"

"github.com/wso2/api-platform/httpkit/httputil"
)

// APIPortalHandler exposes /api-portals CRUD. The generated OpenAPI types
// (api.CreateApiPortalRequest / api.ApiPortalResponse / …) are the wire contract
// AND the service-layer contract — the service speaks in these directly so its
// methods also satisfy pdk.APIPortals for plugins.
type APIPortalHandler struct {
svc *service.APIPortalService
identity *service.IdentityService
slogger *slog.Logger
}

// NewAPIPortalHandler constructs an APIPortalHandler.
func NewAPIPortalHandler(svc *service.APIPortalService, identity *service.IdentityService, slogger *slog.Logger) *APIPortalHandler {
return &APIPortalHandler{svc: svc, identity: identity, slogger: slogger}
}

// CreateAPIPortal — POST /api-portals
func (h *APIPortalHandler) CreateAPIPortal(w http.ResponseWriter, r *http.Request) error {
orgID, ok := middleware.GetOrganizationFromRequest(r)
if !ok {
return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token")
}

var req api.CreateApiPortalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return apperror.NewValidation(err)
}
Comment thread
dushaniw marked this conversation as resolved.

createdBy, err := resolveActorErr(r, h.identity, "create api portal")
if err != nil {
return err
}

resp, err := h.svc.CreateAPIPortal(&req, orgID, createdBy)
if err != nil {
return serviceError(err, fmt.Sprintf("failed to create api portal %q for org %s by user %s", req.Handle, orgID, createdBy))
}

setLocation(w, "api-portals", derefStr(resp.Handle))
httputil.WriteJSON(w, http.StatusCreated, resp)
return nil
}

// GetAPIPortal — GET /api-portals/{apiPortalId}
func (h *APIPortalHandler) GetAPIPortal(w http.ResponseWriter, r *http.Request) error {
orgID, ok := middleware.GetOrganizationFromRequest(r)
if !ok {
return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token")
}

handle := strings.TrimSpace(r.PathValue("apiPortalId"))
if handle == "" {
return apperror.ValidationFailed.New("API Portal ID is required")
}

resp, err := h.svc.GetAPIPortal(handle, orgID)
if err != nil {
return serviceError(err, fmt.Sprintf("failed to get api portal %q in org %s", handle, orgID))
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}

// ListAPIPortals — GET /api-portals
func (h *APIPortalHandler) ListAPIPortals(w http.ResponseWriter, r *http.Request) error {
orgID, ok := middleware.GetOrganizationFromRequest(r)
if !ok {
return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token")
}

opts := parseListOptions(r)

resp, err := h.svc.ListAPIPortals(orgID, opts.Limit, opts.Offset, opts.SortBy, opts.SortOrder, opts.Search)
if err != nil {
return serviceError(err, fmt.Sprintf("failed to list api portals for org %s", orgID))
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}

// UpdateAPIPortal — PUT /api-portals/{apiPortalId}
func (h *APIPortalHandler) UpdateAPIPortal(w http.ResponseWriter, r *http.Request) error {
orgID, ok := middleware.GetOrganizationFromRequest(r)
if !ok {
return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token")
}

handle := strings.TrimSpace(r.PathValue("apiPortalId"))
if handle == "" {
return apperror.ValidationFailed.New("API Portal ID is required")
}

var req api.UpdateApiPortalRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return apperror.NewValidation(err)
}

updatedBy, err := resolveActorErr(r, h.identity, "update api portal")
if err != nil {
return err
}

resp, err := h.svc.UpdateAPIPortal(handle, &req, orgID, updatedBy)
if err != nil {
return serviceError(err, fmt.Sprintf("failed to update api portal %q in org %s by user %s", handle, orgID, updatedBy))
}
httputil.WriteJSON(w, http.StatusOK, resp)
return nil
}

// DeleteAPIPortal — DELETE /api-portals/{apiPortalId}
func (h *APIPortalHandler) DeleteAPIPortal(w http.ResponseWriter, r *http.Request) error {
orgID, ok := middleware.GetOrganizationFromRequest(r)
if !ok {
return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token")
}

handle := strings.TrimSpace(r.PathValue("apiPortalId"))
if handle == "" {
return apperror.ValidationFailed.New("API Portal ID is required")
}

actor, err := resolveActorErr(r, h.identity, "delete api portal")
if err != nil {
return err
}

if err := h.svc.DeleteAPIPortal(handle, orgID, actor); err != nil {
return serviceError(err, fmt.Sprintf("failed to delete api portal %q in org %s by user %s", handle, orgID, actor))
}
w.WriteHeader(http.StatusNoContent)
return nil
}

// RegisterRoutes wires all /api-portals routes onto the shared mux.
func (h *APIPortalHandler) RegisterRoutes(mux router.Router) {
base := constants.APIBasePath + "/api-portals"
mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateAPIPortal))
mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListAPIPortals))
mux.HandleFunc("GET "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.GetAPIPortal))
mux.HandleFunc("PUT "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.UpdateAPIPortal))
mux.HandleFunc("DELETE "+base+"/{apiPortalId}", middleware.MapErrors(h.slogger, h.DeleteAPIPortal))
}

// derefStr returns the pointed-to string or "" when nil. Local helper used by
// setLocation to source the Location header from the api-generated response.
func derefStr(p *string) string {
if p == nil {
return ""
}
return *p
}
Loading
Loading