diff --git a/common/constants/constants.go b/common/constants/constants.go index bb97468950..45b51d50ff 100644 --- a/common/constants/constants.go +++ b/common/constants/constants.go @@ -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" // 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. diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go index 09578cde69..24a4c6389c 100644 --- a/gateway/gateway-controller/pkg/models/runtime_deploy_config.go +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config.go @@ -21,6 +21,7 @@ package models import ( "encoding/json" "fmt" + "strings" "time" "github.com/wso2/api-platform/common/chainkey" @@ -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 { + if handle := strings.TrimSpace(m.ProjectHandle); handle != "" { + return handle + } + return m.ProjectID } // LLMMetadata carries LLM-specific metadata for provider/proxy scenarios. diff --git a/gateway/gateway-controller/pkg/models/runtime_deploy_config_test.go b/gateway/gateway-controller/pkg/models/runtime_deploy_config_test.go new file mode 100644 index 0000000000..183d4cf659 --- /dev/null +++ b/gateway/gateway-controller/pkg/models/runtime_deploy_config_test.go @@ -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) + } + }) +} diff --git a/gateway/gateway-controller/pkg/policyxds/snapshot.go b/gateway/gateway-controller/pkg/policyxds/snapshot.go index ea9bccc302..cf4f68d831 100644 --- a/gateway/gateway-controller/pkg/policyxds/snapshot.go +++ b/gateway/gateway-controller/pkg/policyxds/snapshot.go @@ -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, diff --git a/gateway/gateway-controller/pkg/transform/restapi.go b/gateway/gateway-controller/pkg/transform/restapi.go index 953f053802..a83186bab4 100644 --- a/gateway/gateway-controller/pkg/transform/restapi.go +++ b/gateway/gateway-controller/pkg/transform/restapi.go @@ -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 { @@ -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", diff --git a/gateway/gateway-controller/pkg/transform/restapi_test.go b/gateway/gateway-controller/pkg/transform/restapi_test.go index 9a8606f2e4..57ecb990da 100644 --- a/gateway/gateway-controller/pkg/transform/restapi_test.go +++ b/gateway/gateway-controller/pkg/transform/restapi_test.go @@ -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" @@ -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 { diff --git a/gateway/gateway-controller/pkg/xds/translator.go b/gateway/gateway-controller/pkg/xds/translator.go index ca65e01235..241993d7f6 100644 --- a/gateway/gateway-controller/pkg/xds/translator.go +++ b/gateway/gateway-controller/pkg/xds/translator.go @@ -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 @@ -1508,6 +1508,24 @@ func extractProjectIDFromConfig(cfg *models.StoredConfig) string { return "" } +// extractProjectHandleFromConfig reads the analytics-facing project handle annotation. +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 "" diff --git a/platform-api/internal/model/api.go b/platform-api/internal/model/api.go index 64d473962f..07e6be44eb 100644 --- a/platform-api/internal/model/api.go +++ b/platform-api/internal/model/api.go @@ -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"` diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go index 115179d061..7bd40ccc6a 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -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) { diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go index 46696c3ee1..155864e6dc 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -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 diff --git a/platform-api/internal/service/api_test.go b/platform-api/internal/service/api_test.go index e74b70e333..2929086408 100644 --- a/platform-api/internal/service/api_test.go +++ b/platform-api/internal/service/api_test.go @@ -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" @@ -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 ©, 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") + } +} diff --git a/platform-api/internal/service/artifact_runtime_immutable_test.go b/platform-api/internal/service/artifact_runtime_immutable_test.go index b6ba610ecf..e50a0b2c62 100644 --- a/platform-api/internal/service/artifact_runtime_immutable_test.go +++ b/platform-api/internal/service/artifact_runtime_immutable_test.go @@ -90,6 +90,27 @@ func TestRESTRuntimeArtifactGuard(t *testing.T) { } }) + t.Run("metadata-only edit allowed when project handle is set", func(t *testing.T) { + existing := baseRESTAPI(constants.OriginDP) + existing.ProjectHandle = "new-project" + updated := baseRESTAPI(constants.OriginDP) + updated.ProjectHandle = existing.ProjectHandle + updated.Description = "a shiny new description" + if err := svc.ensureRESTRuntimeArtifactUnchanged(existing, updated); err != nil { + t.Errorf("metadata-only edit with project handle rejected: %v", err) + } + }) + + t.Run("missing project handle rejects harmless edit", func(t *testing.T) { + existing := baseRESTAPI(constants.OriginDP) + existing.ProjectHandle = "new-project" + updated := baseRESTAPI(constants.OriginDP) + updated.Description = "a shiny new description" + if err := svc.ensureRESTRuntimeArtifactUnchanged(existing, updated); !apperror.ArtifactRuntimeImmutable.Is(err) { + t.Errorf("without ProjectHandle on updated: got %v, want read-only", err) + } + }) + t.Run("upstream edit rejected", func(t *testing.T) { existing := baseRESTAPI(constants.OriginDP) updated := baseRESTAPI(constants.OriginDP) diff --git a/platform-api/internal/utils/api.go b/platform-api/internal/utils/api.go index 3c14ce1982..45e908df00 100644 --- a/platform-api/internal/utils/api.go +++ b/platform-api/internal/utils/api.go @@ -534,14 +534,21 @@ func (u *APIUtil) BuildAPIDeploymentYAML(apiModel *model.API) (*dto.APIDeploymen apiType = constants.WebSubApi } + annotations := map[string]string{ + // Stable internal id — keep UUID for consumers that depend on project-id format. + commonconstants.AnnotationProjectID: apiModel.ProjectID, + } + if handle := strings.TrimSpace(apiModel.ProjectHandle); handle != "" { + // User-facing handle for analytics (Moesif metadata.projectId). + annotations[commonconstants.AnnotationProjectHandle] = handle + } + return &dto.APIDeploymentYAML{ ApiVersion: constants.GatewayApiVersion, Kind: apiType, Metadata: dto.DeploymentMetadata{ - Name: apiModel.Handle, - Annotations: map[string]string{ - commonconstants.AnnotationProjectID: apiModel.ProjectID, - }, + Name: apiModel.Handle, + Annotations: annotations, Labels: map[string]string{ commonconstants.DeprecatedLabelProjectID: apiModel.ProjectID, }, diff --git a/platform-api/internal/utils/api_test.go b/platform-api/internal/utils/api_test.go index 823e1990ac..08844deeed 100644 --- a/platform-api/internal/utils/api_test.go +++ b/platform-api/internal/utils/api_test.go @@ -23,6 +23,7 @@ import ( "gopkg.in/yaml.v3" + commonconstants "github.com/wso2/api-platform/common/constants" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/dto" @@ -654,6 +655,70 @@ func TestBuildAPIDeploymentYAML(t *testing.T) { } } +func TestBuildAPIDeploymentYAMLStampsProjectIDAndHandle(t *testing.T) { + util := &APIUtil{} + ctx := "/test" + projectUUID := "019feb20-bd8f-74f1-9489-8814a129cd80" + apiModel := &model.API{ + Handle: "test-api-handle", + Kind: constants.RestApi, + ProjectID: projectUUID, + ProjectHandle: "new-project", + Configuration: model.RestAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "http://backend:8080"}, + }, + }, + } + + deploymentStruct, err := util.BuildAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("BuildAPIDeploymentYAML() error = %v", err) + } + + gotID := deploymentStruct.Metadata.Annotations[commonconstants.AnnotationProjectID] + if gotID != projectUUID { + t.Fatalf("project-id annotation = %q, want UUID %q", gotID, projectUUID) + } + gotHandle := deploymentStruct.Metadata.Annotations[commonconstants.AnnotationProjectHandle] + if gotHandle != "new-project" { + t.Fatalf("project-handle annotation = %q, want %q", gotHandle, "new-project") + } + if deploymentStruct.Metadata.Labels[commonconstants.DeprecatedLabelProjectID] != projectUUID { + t.Fatalf("deprecated project-id label = %q, want UUID %q", + deploymentStruct.Metadata.Labels[commonconstants.DeprecatedLabelProjectID], projectUUID) + } +} + +func TestBuildAPIDeploymentYAMLOmitsProjectHandleWhenUnset(t *testing.T) { + util := &APIUtil{} + ctx := "/test" + projectUUID := "019feb20-bd8f-74f1-9489-8814a129cd80" + apiModel := &model.API{ + Handle: "test-api-handle", + Kind: constants.RestApi, + ProjectID: projectUUID, + Configuration: model.RestAPIConfig{ + Context: &ctx, + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{URL: "http://backend:8080"}, + }, + }, + } + + deploymentStruct, err := util.BuildAPIDeploymentYAML(apiModel) + if err != nil { + t.Fatalf("BuildAPIDeploymentYAML() error = %v", err) + } + if _, ok := deploymentStruct.Metadata.Annotations[commonconstants.AnnotationProjectHandle]; ok { + t.Fatal("expected project-handle annotation to be omitted when ProjectHandle is empty") + } + if got := deploymentStruct.Metadata.Annotations[commonconstants.AnnotationProjectID]; got != projectUUID { + t.Fatalf("project-id annotation = %q, want UUID %q", got, projectUUID) + } +} + // TestUpstreamConfigModelToAPI_RedactsAuthValue proves ModelToRESTAPI never // exposes the real upstream auth credential in a read response — matching the // LLM Provider/Proxy and MCP Proxy mappers' redaction behaviour.