Skip to content
Open
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
5 changes: 5 additions & 0 deletions common/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ package constants

const (
// AnnotationProjectID is the domain-prefixed annotation key for project identity on API resources.
// Control-plane deployments stamp the internal project UUID here. Gateway import still
// accepts a project handle in this key for DP-originated artifacts.
AnnotationProjectID = "gateway.api-platform.wso2.com/project-id"
// AnnotationProjectHandle is the user-facing project handle used for analytics
// (e.g. Moesif metadata.projectId). Prefer this over AnnotationProjectID when both are set.
AnnotationProjectHandle = "gateway.api-platform.wso2.com/project-handle"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveImportProject still reads the dual-typed project-id annotation and passes it to a handle lookup, so control-plane artifacts with UUIDs would fail if they reach this path.

Since we now have a dedicated handle annotation, can the importer prefer the new key and fall back to project-id for older artifacts? This would also let us eventually make project-id consistently UUID-based and remove the current ambiguity.

// DeprecatedLabelProjectID is the bare label key for project identity (deprecated; use AnnotationProjectID).
DeprecatedLabelProjectID = "project-id"
// AnnotationArtifactID is the annotation key that pins the artifact UUID on an API resource.
Expand Down
25 changes: 18 additions & 7 deletions gateway/gateway-controller/pkg/models/runtime_deploy_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package models
import (
"encoding/json"
"fmt"
"strings"
"time"

"github.com/wso2/api-platform/common/chainkey"
Expand All @@ -47,13 +48,23 @@ type RuntimeDeployConfig struct {

// Metadata contains identity information for the deployed API.
type Metadata struct {
UUID string
Kind string
Handle string
Version string
DisplayName string
ProjectID string
LLM *LLMMetadata // nil for non-LLM kinds
UUID string
Kind string
Handle string
Version string
DisplayName string
ProjectID string // from gateway.api-platform.wso2.com/project-id (UUID for CP deploys)
ProjectHandle string // from gateway.api-platform.wso2.com/project-handle (analytics-facing)
LLM *LLMMetadata // nil for non-LLM kinds
}

// AnalyticsProjectRef returns the project identity to publish for analytics
// (Moesif metadata.projectId). Prefer the user-facing handle when present.
func (m Metadata) AnalyticsProjectRef() string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requirement is to send both to Moesif. AnalyticsProjectRef() currently replaces the UUID with the handle, which changes the meaning of the existing projectId attribute and could break existing dashboards/alerts and other consumers of that value.

Can we keep the existing projectId as the UUID and add the handle as a separate attribute instead? That would satisfy the requirement without changing existing semantics.

if handle := strings.TrimSpace(m.ProjectHandle); handle != "" {
return handle
}
return m.ProjectID
}

// LLMMetadata carries LLM-specific metadata for provider/proxy scenarios.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you 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 models

import "testing"

func TestMetadataAnalyticsProjectRef(t *testing.T) {
t.Run("prefers project handle for analytics", func(t *testing.T) {
md := Metadata{
ProjectID: "019feb20-bd8f-74f1-9489-8814a129cd80",
ProjectHandle: "new-project",
}
if got := md.AnalyticsProjectRef(); got != "new-project" {
t.Fatalf("AnalyticsProjectRef() = %q, want handle", got)
}
})

t.Run("falls back to project id when handle unset", func(t *testing.T) {
md := Metadata{ProjectID: "019feb20-bd8f-74f1-9489-8814a129cd80"}
if got := md.AnalyticsProjectRef(); got != md.ProjectID {
t.Fatalf("AnalyticsProjectRef() = %q, want project id", got)
}
})

t.Run("trims padded handle", func(t *testing.T) {
md := Metadata{
ProjectID: "019feb20-bd8f-74f1-9489-8814a129cd80",
ProjectHandle: " new-project ",
}
if got := md.AnalyticsProjectRef(); got != "new-project" {
t.Fatalf("AnalyticsProjectRef() = %q, want trimmed handle", got)
}
})

t.Run("whitespace-only handle falls back to project id", func(t *testing.T) {
md := Metadata{
ProjectID: "019feb20-bd8f-74f1-9489-8814a129cd80",
ProjectHandle: " \t ",
}
if got := md.AnalyticsProjectRef(); got != md.ProjectID {
t.Fatalf("AnalyticsProjectRef() = %q, want project id fallback", got)
}
})
}
2 changes: 1 addition & 1 deletion gateway/gateway-controller/pkg/policyxds/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ func (t *Translator) createRouteConfigResource(
"handle": rdc.Metadata.Handle,
"version": rdc.Metadata.Version,
"display_name": rdc.Metadata.DisplayName,
"project_id": rdc.Metadata.ProjectID,
"project_id": rdc.Metadata.AnalyticsProjectRef(),
"api_context": rdc.Context,
"vhost": route.Vhost,
"path": route.OperationPath,
Expand Down
24 changes: 18 additions & 6 deletions gateway/gateway-controller/pkg/transform/restapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ func extractProjectID(cfg *models.StoredConfig) string {
return ""
}

// extractProjectHandle reads the analytics-facing project handle annotation when present.
func extractProjectHandle(cfg *models.StoredConfig) string {
if annotations := cfg.GetAnnotations(); annotations != nil {
if handle, exists := (*annotations)[commonconstants.AnnotationProjectHandle]; exists {
return strings.TrimSpace(handle)
}
}
return ""
}

func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.RuntimeDeployConfig, error) {
restCfg, ok := cfg.Configuration.(api.RestAPI)
if !ok {
Expand All @@ -87,15 +97,17 @@ func (t *RestAPITransformer) Transform(cfg *models.StoredConfig) (*models.Runtim
apiData := restCfg.Spec

projectID := extractProjectID(cfg)
projectHandle := extractProjectHandle(cfg)

rdc := &models.RuntimeDeployConfig{
Metadata: models.Metadata{
UUID: cfg.UUID,
Kind: cfg.Kind,
Handle: cfg.Handle,
Version: apiData.Version,
DisplayName: apiData.DisplayName,
ProjectID: projectID,
UUID: cfg.UUID,
Kind: cfg.Kind,
Handle: cfg.Handle,
Version: apiData.Version,
DisplayName: apiData.DisplayName,
ProjectID: projectID,
ProjectHandle: projectHandle,
},
Context: strings.ReplaceAll(apiData.Context, "$version", apiData.Version),
PolicyChainResolver: "route-key",
Expand Down
53 changes: 53 additions & 0 deletions gateway/gateway-controller/pkg/transform/restapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
commonconstants "github.com/wso2/api-platform/common/constants"
api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/config"
"github.com/wso2/api-platform/gateway/gateway-controller/pkg/models"
Expand Down Expand Up @@ -86,6 +87,58 @@ func makeRestAPIStoredConfig(apiPolicies []api.Policy, opPolicies []api.Policy)
}
}

func TestRestAPITransformer_PrefersProjectHandleForAnalyticsMetadata(t *testing.T) {
cfg := makeRestAPIStoredConfig(nil, nil)
restAPI := cfg.Configuration.(api.RestAPI)
restAPI.Metadata.Annotations = &map[string]string{
commonconstants.AnnotationProjectID: "019feb20-bd8f-74f1-9489-8814a129cd80",
commonconstants.AnnotationProjectHandle: "new-project",
}
cfg.Configuration = restAPI

transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{})
rdc, err := transformer.Transform(cfg)
require.NoError(t, err)
assert.Equal(t, "019feb20-bd8f-74f1-9489-8814a129cd80", rdc.Metadata.ProjectID)
assert.Equal(t, "new-project", rdc.Metadata.ProjectHandle)
assert.Equal(t, "new-project", rdc.Metadata.AnalyticsProjectRef())
}

func TestRestAPITransformer_TrimsProjectHandleAnnotation(t *testing.T) {
projectUUID := "019feb20-bd8f-74f1-9489-8814a129cd80"
transformer := NewRestAPITransformer(testRouterCfg(), &config.Config{}, map[string]models.PolicyDefinition{})

t.Run("padded handle is trimmed", func(t *testing.T) {
cfg := makeRestAPIStoredConfig(nil, nil)
restAPI := cfg.Configuration.(api.RestAPI)
restAPI.Metadata.Annotations = &map[string]string{
commonconstants.AnnotationProjectID: projectUUID,
commonconstants.AnnotationProjectHandle: " new-project ",
}
cfg.Configuration = restAPI

rdc, err := transformer.Transform(cfg)
require.NoError(t, err)
assert.Equal(t, "new-project", rdc.Metadata.ProjectHandle)
assert.Equal(t, "new-project", rdc.Metadata.AnalyticsProjectRef())
})

t.Run("whitespace-only handle falls back to project id", func(t *testing.T) {
cfg := makeRestAPIStoredConfig(nil, nil)
restAPI := cfg.Configuration.(api.RestAPI)
restAPI.Metadata.Annotations = &map[string]string{
commonconstants.AnnotationProjectID: projectUUID,
commonconstants.AnnotationProjectHandle: " \t ",
}
cfg.Configuration = restAPI

rdc, err := transformer.Transform(cfg)
require.NoError(t, err)
assert.Empty(t, rdc.Metadata.ProjectHandle)
assert.Equal(t, projectUUID, rdc.Metadata.AnalyticsProjectRef())
})
}

// makeRestAPIStoredConfigWithResilience builds a RestAPI StoredConfig whose single
// operation (GET /hello) carries optional API-level and operation-level resilience blocks.
func makeRestAPIStoredConfigWithResilience(apiRes, opRes *api.Resilience) *models.StoredConfig {
Expand Down
20 changes: 19 additions & 1 deletion gateway/gateway-controller/pkg/xds/translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,7 @@ func (t *Translator) translateAPIConfig(cfg *models.StoredConfig, allConfigs []*
templateHandle := t.extractTemplateHandle(cfg, allConfigs)
providerName := t.extractProviderName(cfg, allConfigs)

apiProjectID := extractProjectIDFromConfig(cfg)
apiProjectID := analyticsProjectRefFromConfig(cfg)

// Build a map of upstream definition name -> basePath for dynamic routing
// This allows the policy engine to apply the correct path transformation when UpstreamName is used
Expand Down Expand Up @@ -1508,6 +1508,24 @@ func extractProjectIDFromConfig(cfg *models.StoredConfig) string {
return ""
}

// extractProjectHandleFromConfig reads the analytics-facing project handle annotation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is a no-op here: the value is only passed to the REST route builder, which doesn’t use the project-id parameter. The actual Envoy metadata is stamped by the per-topic route builder, which still uses the original UUID-only extraction. WebSub would keep publishing the UUID while REST publishes the handle, making the same Moesif attribute mean different things by API type.

follow-up: MCP/LLM APIs currently get no project attribute, and MCP would drop annotations during conversion anyway. We should confirm whether those API types are in scope.

func extractProjectHandleFromConfig(cfg *models.StoredConfig) string {
if annotations := cfg.GetAnnotations(); annotations != nil {
if handle, exists := (*annotations)[commonconstants.AnnotationProjectHandle]; exists {
return strings.TrimSpace(handle)
}
}
return ""
}

// analyticsProjectRefFromConfig prefers project-handle for Moesif / analytics metadata.
func analyticsProjectRefFromConfig(cfg *models.StoredConfig) string {
if handle := extractProjectHandleFromConfig(cfg); handle != "" {
return handle
}
return extractProjectIDFromConfig(cfg)
}

func (t *Translator) extractTemplateHandle(cfg *models.StoredConfig, allConfigs []*models.StoredConfig) string {
if cfg.SourceConfiguration == nil {
return ""
Expand Down
1 change: 1 addition & 0 deletions platform-api/internal/model/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type API struct {
CreatedBy string `json:"createdBy,omitempty" db:"created_by"`
UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"`
ProjectID string `json:"projectId" db:"project_uuid"` // FK to Project.ID
ProjectHandle string `json:"-" db:"-"` // Project handle for gateway/analytics metadata (not persisted on API row)
OrganizationID string `json:"organizationId" db:"organization_uuid"` // FK to Organization.ID
CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"`
Expand Down
16 changes: 11 additions & 5 deletions platform-api/internal/repository/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,23 +109,29 @@ func (r *APIRepo) GetAPIByUUID(apiUUID, orgUUID string) (*model.API, error) {
api := &model.API{}

query := `
SELECT uuid, handle, display_name, description, version, created_by, updated_by,
project_uuid, organization_uuid, lifecycle_status, configuration, origin, data_version, created_at, updated_at
FROM rest_apis
WHERE uuid = ? AND organization_uuid = ?
SELECT r.uuid, r.handle, r.display_name, r.description, r.version, r.created_by, r.updated_by,
r.project_uuid, r.organization_uuid, r.lifecycle_status, r.configuration, r.origin, r.data_version,
r.created_at, r.updated_at, p.handle
FROM rest_apis r
LEFT JOIN projects p ON p.uuid = r.project_uuid AND p.organization_uuid = r.organization_uuid
WHERE r.uuid = ? AND r.organization_uuid = ?
`

var configJSON sql.NullString
var createdBy, updatedBy sql.NullString
var projectHandle sql.NullString
err := r.db.QueryRow(r.db.Rebind(query), apiUUID, orgUUID).Scan(
&api.ID, &api.Handle, &api.Name, &api.Description,
&api.Version, &createdBy, &updatedBy, &api.ProjectID, &api.OrganizationID, &api.LifeCycleStatus,
&configJSON, &api.Origin, &api.DataVersion, &api.CreatedAt, &api.UpdatedAt)
&configJSON, &api.Origin, &api.DataVersion, &api.CreatedAt, &api.UpdatedAt, &projectHandle)
api.Kind = constants.RestApi
api.CreatedBy = createdBy.String
if updatedBy.Valid {
api.UpdatedBy = updatedBy.String
}
if projectHandle.Valid {
api.ProjectHandle = projectHandle.String
}

if err != nil {
if errors.Is(err, sql.ErrNoRows) {
Expand Down
1 change: 1 addition & 0 deletions platform-api/internal/service/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ func (s *APIService) UpdateAPI(apiUUID string, req *api.RESTAPI, orgUUID, update
// and defaults the origin to control_plane).
updatedAPIModel.Handle = existingAPIModel.Handle
updatedAPIModel.ProjectID = existingAPIModel.ProjectID
updatedAPIModel.ProjectHandle = existingAPIModel.ProjectHandle
updatedAPIModel.Kind = existingAPIModel.Kind
updatedAPIModel.Origin = existingAPIModel.Origin
updatedAPIModel.CreatedBy = existingAPIModel.CreatedBy
Expand Down
72 changes: 72 additions & 0 deletions platform-api/internal/service/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"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/model"
"github.com/wso2/api-platform/platform-api/internal/repository"
"github.com/wso2/api-platform/platform-api/internal/utils"
Expand Down Expand Up @@ -854,3 +855,74 @@ func TestAPIServiceUpdate_MissingSecretRef_Rejected(t *testing.T) {
t.Error("expected API update to be aborted, but repo.UpdateAPI was called")
}
}

func TestAPIServiceUpdate_DPOriginAllowsMetadataEditWithProjectHandle(t *testing.T) {
contextPath := "/reading"
existing := &model.API{
ID: "api-uuid",
Handle: "reading-list-api",
Name: "Reading List API",
OrganizationID: "org-1",
ProjectID: "019feb20-aaaa-bbbb-cccc-ddddeeeeffff",
ProjectHandle: "new-project",
Origin: constants.OriginDP,
Kind: constants.RestApi,
Version: "v1.0",
Configuration: model.RestAPIConfig{
Name: "Reading List API",
Version: "v1.0",
Context: &contextPath,
Upstream: model.UpstreamConfig{
Main: &model.UpstreamEndpoint{URL: "https://backend.example.com"},
},
Operations: []model.Operation{
{Name: "get", Request: &model.OperationRequest{Method: "GET", Path: "/items"}},
},
},
}

apiRepo := &mockAPIRepository{
getByUUIDFunc: func(apiUUID, orgUUID string) (*model.API, error) {
copy := *existing
return &copy, nil
},
}
service := &APIService{
apiRepo: apiRepo,
projectRepo: &mockProjectRepository{
projectByUUID: &model.Project{
ID: existing.ProjectID,
Handle: existing.ProjectHandle,
},
},
apiUtil: &utils.APIUtil{},
identity: newTestIdentityService(),
auditRepo: &noopAuditRepo{},
}

req := &api.RESTAPI{
DisplayName: "Reading List API",
Context: contextPath,
Version: "v1.0",
Description: ptr("Updated description"),
Upstream: api.Upstream{
Main: api.UpstreamDefinition{
Url: utils.StringPtrIfNotEmpty("https://backend.example.com"),
},
},
Operations: &[]api.Operation{
{Request: api.OperationRequest{Method: "GET", Path: "/items"}},
},
}

_, err := service.UpdateAPI("api-uuid", req, "org-1", "alice")
if err != nil {
t.Fatalf("UpdateAPI() error = %v", err)
}
if apiRepo.updated == nil {
t.Fatal("expected repo.UpdateAPI to be called")
}
if apiRepo.updated.Description != "Updated description" {
t.Errorf("description = %q, want %q", apiRepo.updated.Description, "Updated description")
}
}
Loading
Loading