Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
041fedf
refactor(api-portals): move to shared-key S2S auth (replaces authType…
dushaniw Sep 9, 2026
4e737ba
api-portals: make metadata column nullable, store NULL for empty maps
dushaniw Sep 9, 2026
4ac1f42
api-portals: implement SharedKeyAuthProvider + registry Get
dushaniw Sep 10, 2026
56edf4e
api-portals: restore repo/service/handler test coverage for shared-ke…
dushaniw Sep 11, 2026
3643d50
api-portals: address review comments (openapi description + registry …
dushaniw Sep 11, 2026
5d08afb
api-portals: prune long code comments across the resource
dushaniw Sep 11, 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
290 changes: 271 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
17 changes: 17 additions & 0 deletions platform-api/internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,23 @@ var ValidGatewayTokenStatuses = map[string]bool{
GatewayTokenStatusRevoked: true,
}

// API Portal status constants.
const (
APIPortalStatusPending = "pending"
APIPortalStatusActive = "active"
APIPortalStatusFailed = "failed"
)

// API Portal outbound-auth constants. The scheme is a custom RFC 7235 name,
// not OAuth 2.0 Bearer; the portal middleware sha256s the raw for verification.
const (
// APIPortalSharedKeyAuthScheme is the Authorization-header scheme name; matched case-insensitively by the portal.
APIPortalSharedKeyAuthScheme = "SharedKey"

// APIPortalSharedKeyHexLength is the required raw-key length in hex chars (32 bytes of entropy).
APIPortalSharedKeyHexLength = 64
)

// 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
20 changes: 20 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,25 @@ 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',
internal_auth_key BYTEA NOT NULL,
metadata BYTEA,
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 +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_applications_org ON applications(organization_uuid);
CREATE INDEX IF NOT EXISTS idx_applications_project_id ON applications(organization_uuid, project_uuid);
Expand Down
20 changes: 20 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,25 @@ 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',
internal_auth_key BLOB NOT NULL,
metadata BLOB,
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 +491,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
22 changes: 22 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,26 @@ 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',
internal_auth_key VARBINARY(MAX) NOT NULL,
metadata VARBINARY(MAX),
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 +574,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
183 changes: 183 additions & 0 deletions platform-api/internal/handler/api_portal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* 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.
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 handles 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 handles 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 handles 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 handles 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 handles 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.
func derefStr(p *string) string {
if p == nil {
return ""
}
return *p
}
Loading
Loading