diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index 068d03c457..9f8caa154d 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -99,6 +99,11 @@ const ( DeploymentResponseStatusUNDEPLOYING DeploymentResponseStatus = "UNDEPLOYING" ) +// Defines values for ErrorStatus. +const ( + ErrorStatusError ErrorStatus = "error" +) + // Defines values for ExtractionIdentifierLocation. const ( ExtractionIdentifierLocationHeader ExtractionIdentifierLocation = "header" @@ -339,6 +344,42 @@ const ( DeploymentStatusQUNDEPLOYING DeploymentStatusQ = "UNDEPLOYING" ) +// Defines values for SortByQ. +const ( + SortByQCreatedAt SortByQ = "createdAt" + SortByQName SortByQ = "name" +) + +// Defines values for SortOrderQ. +const ( + SortOrderQAsc SortOrderQ = "asc" + SortOrderQDesc SortOrderQ = "desc" +) + +// Defines values for ListApplicationsParamsSortBy. +const ( + ListApplicationsParamsSortByCreatedAt ListApplicationsParamsSortBy = "createdAt" + ListApplicationsParamsSortByName ListApplicationsParamsSortBy = "name" +) + +// Defines values for ListApplicationsParamsSortOrder. +const ( + ListApplicationsParamsSortOrderAsc ListApplicationsParamsSortOrder = "asc" + ListApplicationsParamsSortOrderDesc ListApplicationsParamsSortOrder = "desc" +) + +// Defines values for ListGatewaysParamsSortBy. +const ( + ListGatewaysParamsSortByCreatedAt ListGatewaysParamsSortBy = "createdAt" + ListGatewaysParamsSortByName ListGatewaysParamsSortBy = "name" +) + +// Defines values for ListGatewaysParamsSortOrder. +const ( + ListGatewaysParamsSortOrderAsc ListGatewaysParamsSortOrder = "asc" + ListGatewaysParamsSortOrderDesc ListGatewaysParamsSortOrder = "desc" +) + // Defines values for GetLLMProviderDeploymentsParamsStatus. const ( GetLLMProviderDeploymentsParamsStatusARCHIVED GetLLMProviderDeploymentsParamsStatus = "ARCHIVED" @@ -376,6 +417,30 @@ const ( RestApi ListUserAPIKeysParamsType = "RestApi" ) +// Defines values for ListProjectsParamsSortBy. +const ( + ListProjectsParamsSortByCreatedAt ListProjectsParamsSortBy = "createdAt" + ListProjectsParamsSortByName ListProjectsParamsSortBy = "name" +) + +// Defines values for ListProjectsParamsSortOrder. +const ( + ListProjectsParamsSortOrderAsc ListProjectsParamsSortOrder = "asc" + ListProjectsParamsSortOrderDesc ListProjectsParamsSortOrder = "desc" +) + +// Defines values for ListRESTAPIsParamsSortBy. +const ( + CreatedAt ListRESTAPIsParamsSortBy = "createdAt" + Name ListRESTAPIsParamsSortBy = "name" +) + +// Defines values for ListRESTAPIsParamsSortOrder. +const ( + Asc ListRESTAPIsParamsSortOrder = "asc" + Desc ListRESTAPIsParamsSortOrder = "desc" +) + // Defines values for GetDeploymentsParamsStatus. const ( GetDeploymentsParamsStatusARCHIVED GetDeploymentsParamsStatus = "ARCHIVED" @@ -913,6 +978,16 @@ type CreateSubscriptionRequestKind string // CreateSubscriptionRequestStatus Subscription status (default ACTIVE) type CreateSubscriptionRequestStatus string +// CustomPolicyListResponse defines model for CustomPolicyListResponse. +type CustomPolicyListResponse struct { + // Count Number of custom policies in current response + Count int `binding:"required" json:"count" yaml:"count"` + + // List List of custom policies + List []CustomPolicyResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + // CustomPolicyResponse A custom policy stored in the platform's custom policy registry. type CustomPolicyResponse struct { CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` @@ -955,11 +1030,12 @@ type DeployRequest struct { // DeploymentListResponse defines model for DeploymentListResponse. type DeploymentListResponse struct { - // Count Total number of deployments + // Count Number of deployments in current response Count int `binding:"required" json:"count" yaml:"count"` // List List of deployments - List []DeploymentResponse `binding:"required" json:"list" yaml:"list"` + List []DeploymentResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` } // DeploymentResponse defines model for DeploymentResponse. @@ -1007,20 +1083,30 @@ type DeploymentResponse struct { // - ARCHIVED: Historical deployment, can be rolled back type DeploymentResponseStatus string -// Error defines model for Error. +// Error The single error shape returned by every failed request across the API. type Error struct { - Code int64 `binding:"required" json:"code" yaml:"code"` + // Code Stable, machine-readable error code from the error catalog, in the form `_` (e.g. `REST_API_NOT_FOUND`). Clients and agents should branch on this, not on the HTTP status. + Code string `binding:"required" json:"code" yaml:"code"` - // Details A detailed description about the error message - Details *string `json:"details,omitempty" yaml:"details,omitempty"` + // Details Optional structured metadata specific to this error condition (e.g. the resources referencing a secret that blocked its deletion). Shape varies by `code`; absent when not applicable. + Details *map[string]interface{} `json:"details,omitempty" yaml:"details,omitempty"` - // Errors List of specific errors (useful for validation errors) - Errors *[]map[string]interface{} `json:"errors,omitempty" yaml:"errors,omitempty"` + // Errors Per-field validation failures. Present when the error is a validation failure. + Errors *[]FieldError `json:"errors,omitempty" yaml:"errors,omitempty"` - // Title Error message - Title string `binding:"required" json:"title" yaml:"title"` + // Message Human-readable description of the error. + Message string `binding:"required" json:"message" yaml:"message"` + + // Status Always the literal "error". + Status ErrorStatus `binding:"required" json:"status" yaml:"status"` + + // TrackingId Correlation ID for server-side failures. Present only on 5xx responses; quote it when reporting the error so operators can find the corresponding server log entry. + TrackingId *openapi_types.UUID `json:"trackingId,omitempty" yaml:"trackingId,omitempty"` } +// ErrorStatus Always the literal "error". +type ErrorStatus string + // ExpirationDuration defines model for ExpirationDuration. type ExpirationDuration struct { // Duration Duration value (must be positive) @@ -1042,6 +1128,15 @@ type ExtractionIdentifier struct { // ExtractionIdentifierLocation Where to find the token information type ExtractionIdentifierLocation string +// FieldError defines model for FieldError. +type FieldError struct { + // Field Path of the offending field. + Field string `binding:"required" json:"field" yaml:"field"` + + // Message Why the field failed validation. + Message string `binding:"required" json:"message" yaml:"message"` +} + // GatewayListResponse defines model for GatewayListResponse. type GatewayListResponse struct { // Count Number of items in current response @@ -1138,6 +1233,16 @@ type GatewayStatusResponse struct { IsCritical *bool `json:"isCritical,omitempty" yaml:"isCritical,omitempty"` } +// GatewayTokenListResponse defines model for GatewayTokenListResponse. +type GatewayTokenListResponse struct { + // Count Number of tokens in current response + Count int `binding:"required" json:"count" yaml:"count"` + + // List List of active tokens + List []TokenInfoResponse `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + // LLMAccessControl defines model for LLMAccessControl. type LLMAccessControl struct { // Exceptions Path exceptions to the access control mode @@ -1266,11 +1371,12 @@ type LLMProvider struct { // LLMProviderAPIKeyListResponse defines model for LLMProviderAPIKeyListResponse. type LLMProviderAPIKeyListResponse struct { - // Count Total number of API keys returned + // Count Number of API keys in current response Count int `binding:"required" json:"count" yaml:"count"` - // Items List of API keys - Items []APIKeyItem `binding:"required" json:"items" yaml:"items"` + // List List of API keys + List []APIKeyItem `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` } // LLMProviderListItem defines model for LLMProviderListItem. @@ -1512,11 +1618,12 @@ type LLMProxy struct { // LLMProxyAPIKeyListResponse defines model for LLMProxyAPIKeyListResponse. type LLMProxyAPIKeyListResponse struct { - // Count Total number of API keys returned + // Count Number of API keys in current response Count int `binding:"required" json:"count" yaml:"count"` - // Items List of API keys - Items []APIKeyItem `binding:"required" json:"items" yaml:"items"` + // List List of API keys + List []APIKeyItem `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` } // LLMProxyListItem defines model for LLMProxyListItem. @@ -2123,18 +2230,10 @@ type SecretCreateRequest struct { // SecretCreateRequestType defines model for SecretCreateRequest.Type. type SecretCreateRequestType string -// SecretDeleteConflict defines model for SecretDeleteConflict. -type SecretDeleteConflict struct { - Error *string `json:"error,omitempty" yaml:"error,omitempty"` - References *[]struct { - Handle *string `json:"handle,omitempty" yaml:"handle,omitempty"` - Name *string `json:"name,omitempty" yaml:"name,omitempty"` - Type *string `json:"type,omitempty" yaml:"type,omitempty"` - } `json:"references,omitempty" yaml:"references,omitempty"` -} - // SecretListResponse defines model for SecretListResponse. type SecretListResponse struct { + // Count Number of secrets in current response + Count int `binding:"required" json:"count" yaml:"count"` List []SecretSummary `binding:"required" json:"list" yaml:"list"` Pagination Pagination `json:"pagination" yaml:"pagination"` } @@ -2253,9 +2352,11 @@ type SubscriptionStatus string // SubscriptionListResponse defines model for SubscriptionListResponse. type SubscriptionListResponse struct { // Count Number of subscriptions in current response - Count int `binding:"required" json:"count" yaml:"count"` - Pagination Pagination `json:"pagination" yaml:"pagination"` - Subscriptions []Subscription `binding:"required" json:"subscriptions" yaml:"subscriptions"` + Count int `binding:"required" json:"count" yaml:"count"` + + // List List of subscriptions in current response + List []Subscription `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` } // SubscriptionPlan defines model for SubscriptionPlan. @@ -2316,8 +2417,12 @@ type SubscriptionPlanLimitTimeUnit string // SubscriptionPlanListResponse defines model for SubscriptionPlanListResponse. type SubscriptionPlanListResponse struct { - Count *int `json:"count,omitempty" yaml:"count,omitempty"` - SubscriptionPlans *[]SubscriptionPlan `json:"subscriptionPlans,omitempty" yaml:"subscriptionPlans,omitempty"` + // Count Number of subscription plans in current response + Count int `binding:"required" json:"count" yaml:"count"` + + // List List of subscription plans in current response + List []SubscriptionPlan `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` } // TimeUnit Time unit for API key expiration duration @@ -2493,11 +2598,12 @@ type UserAPIKeyItemStatus string // UserAPIKeyListResponse defines model for UserAPIKeyListResponse. type UserAPIKeyListResponse struct { - // Count Total number of API keys returned + // Count Number of API keys in current response Count int `binding:"required" json:"count" yaml:"count"` - // Items List of API keys - Items []UserAPIKeyItem `binding:"required" json:"items" yaml:"items"` + // List List of API keys + List []UserAPIKeyItem `binding:"required" json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` } // ApiId defines model for apiId. @@ -2524,37 +2630,55 @@ type GatewayId = string // GatewayIdQ defines model for gatewayId-Q. type GatewayIdQ = string +// LimitQ defines model for limit-Q. +type LimitQ = int + // MappedKeyId defines model for mappedKeyId. type MappedKeyId = string +// OffsetQ defines model for offset-Q. +type OffsetQ = int + // ProjectId defines model for projectId. type ProjectId = string // ProjectIdQ defines model for projectId-Q. type ProjectIdQ = string +// QueryQ defines model for query-Q. +type QueryQ = string + +// SortByQ defines model for sortBy-Q. +type SortByQ string + +// SortOrderQ defines model for sortOrder-Q. +type SortOrderQ string + // TokenId defines model for tokenId. type TokenId = openapi_types.UUID -// BadRequest defines model for BadRequest. +// BadRequest The single error shape returned by every failed request across the API. type BadRequest = Error -// Conflict defines model for Conflict. +// Conflict The single error shape returned by every failed request across the API. type Conflict = Error -// Forbidden defines model for Forbidden. +// Forbidden The single error shape returned by every failed request across the API. type Forbidden = Error -// InternalServerError defines model for InternalServerError. +// GatewayConnectionUnavailable The single error shape returned by every failed request across the API. +type GatewayConnectionUnavailable = Error + +// InternalServerError The single error shape returned by every failed request across the API. type InternalServerError = Error -// NotFound defines model for NotFound. +// NotFound The single error shape returned by every failed request across the API. type NotFound = Error -// ServiceUnavailable defines model for ServiceUnavailable. +// ServiceUnavailable The single error shape returned by every failed request across the API. type ServiceUnavailable = Error -// Unauthorized defines model for Unauthorized. +// Unauthorized The single error shape returned by every failed request across the API. type Unauthorized = Error // ListApplicationsParams defines parameters for ListApplications. @@ -2562,20 +2686,35 @@ type ListApplicationsParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project to filter APIs by. ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` - // Limit Maximum number of applications to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` - // Offset Number of applications to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListApplicationsParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListApplicationsParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` } +// ListApplicationsParamsSortBy defines parameters for ListApplications. +type ListApplicationsParamsSortBy string + +// ListApplicationsParamsSortOrder defines parameters for ListApplications. +type ListApplicationsParamsSortOrder string + // ListApplicationAPIKeysParams defines parameters for ListApplicationAPIKeys. type ListApplicationAPIKeysParams struct { - // Limit Maximum number of mapped API keys to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of mapped API keys to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // RemoveApplicationAPIKeyParams defines parameters for RemoveApplicationAPIKey. @@ -2586,20 +2725,29 @@ type RemoveApplicationAPIKeyParams struct { // ListApplicationAssociationsParams defines parameters for ListApplicationAssociations. type ListApplicationAssociationsParams struct { - // Limit Maximum number of associations to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of associations to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // ListApplicationAssociationAPIKeysParams defines parameters for ListApplicationAssociationAPIKeys. type ListApplicationAssociationAPIKeysParams struct { - // Limit Maximum number of mapped API keys to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of mapped API keys to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// ListGatewayCustomPoliciesParams defines parameters for ListGatewayCustomPolicies. +type ListGatewayCustomPoliciesParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // SyncCustomPolicyParams defines parameters for SyncCustomPolicy. @@ -2614,16 +2762,49 @@ type SyncCustomPolicyParams struct { PolicyVersion string `form:"policyVersion" json:"policyVersion" yaml:"policyVersion"` } +// ListGatewaysParams defines parameters for ListGateways. +type ListGatewaysParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListGatewaysParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListGatewaysParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` +} + +// ListGatewaysParamsSortBy defines parameters for ListGateways. +type ListGatewaysParamsSortBy string + +// ListGatewaysParamsSortOrder defines parameters for ListGateways. +type ListGatewaysParamsSortOrder string + +// ListGatewayTokensParams defines parameters for ListGatewayTokens. +type ListGatewayTokensParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + // ListLLMProviderTemplatesParams defines parameters for ListLLMProviderTemplates. type ListLLMProviderTemplatesParams struct { // Query URL-encoded search DSL. `query=latest:true` lists only the latest version of each family; `query=groupId:` lists that family's versions; adding `&version:` returns the single full template for that version. Terms are `&`-separated `key:value` pairs and the whole value is percent-encoded (e.g. groupId%3Aopenai%26version%3Av2.0). Query *string `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` - // Limit Maximum number of LLM provider templates to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of LLM provider templates to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // CopyLLMProviderTemplateVersionParams defines parameters for CopyLLMProviderTemplateVersion. @@ -2645,11 +2826,20 @@ type SetLLMProviderTemplateVersionEnabledJSONBody struct { // ListLLMProvidersParams defines parameters for ListLLMProviders. type ListLLMProvidersParams struct { - // Limit Maximum number of LLM providers to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of LLM providers to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// ListLLMProviderAPIKeysParams defines parameters for ListLLMProviderAPIKeys. +type ListLLMProviderAPIKeysParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetLLMProviderDeploymentsParams defines parameters for GetLLMProviderDeployments. @@ -2659,6 +2849,12 @@ type GetLLMProviderDeploymentsParams struct { // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) Status *GetLLMProviderDeploymentsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetLLMProviderDeploymentsParamsStatus defines parameters for GetLLMProviderDeployments. @@ -2678,11 +2874,11 @@ type UndeployLLMProviderDeploymentParams struct { // ListLLMProxiesByProviderParams defines parameters for ListLLMProxiesByProvider. type ListLLMProxiesByProviderParams struct { - // Limit Maximum number of LLM proxies to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of LLM proxies to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // ListLLMProxiesParams defines parameters for ListLLMProxies. @@ -2690,11 +2886,20 @@ type ListLLMProxiesParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project to filter APIs by. ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` - // Limit Maximum number of LLM proxies to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + +// ListLLMProxyAPIKeysParams defines parameters for ListLLMProxyAPIKeys. +type ListLLMProxyAPIKeysParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of LLM proxies to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetLLMProxyDeploymentsParams defines parameters for GetLLMProxyDeployments. @@ -2704,6 +2909,12 @@ type GetLLMProxyDeploymentsParams struct { // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) Status *GetLLMProxyDeploymentsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetLLMProxyDeploymentsParamsStatus defines parameters for GetLLMProxyDeployments. @@ -2726,11 +2937,11 @@ type ListMCPProxiesParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project to filter APIs by. ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` - // Limit Maximum number of MCP proxies to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of MCP proxies to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetMCPProxyDeploymentsParams defines parameters for GetMCPProxyDeployments. @@ -2740,6 +2951,12 @@ type GetMCPProxyDeploymentsParams struct { // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) Status *GetMCPProxyDeploymentsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetMCPProxyDeploymentsParamsStatus defines parameters for GetMCPProxyDeployments. @@ -2762,6 +2979,12 @@ type ListUserAPIKeysParams struct { // Type Comma-separated list of artifact types to filter by. // If omitted, all types are returned. Type *[]ListUserAPIKeysParamsType `form:"type,omitempty" json:"type,omitempty" yaml:"type,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // ListUserAPIKeysParamsType defines parameters for ListUserAPIKeys. @@ -2769,19 +2992,64 @@ type ListUserAPIKeysParamsType string // ListOrganizationsParams defines parameters for ListOrganizations. type ListOrganizationsParams struct { - // Limit Maximum number of organizations to return per page. - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Zero-based index of the first organization to return. - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } +// ListProjectsParams defines parameters for ListProjects. +type ListProjectsParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListProjectsParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListProjectsParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` +} + +// ListProjectsParamsSortBy defines parameters for ListProjects. +type ListProjectsParamsSortBy string + +// ListProjectsParamsSortOrder defines parameters for ListProjects. +type ListProjectsParamsSortOrder string + // ListRESTAPIsParams defines parameters for ListRESTAPIs. type ListRESTAPIsParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project to filter APIs by. ProjectId ProjectIdQ `form:"projectId" json:"projectId" yaml:"projectId"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + + // SortBy Field to sort the collection by. An unrecognized value falls back to the default sort (createdAt). + SortBy *ListRESTAPIsParamsSortBy `form:"sortBy,omitempty" json:"sortBy,omitempty" yaml:"sortBy,omitempty"` + + // SortOrder Sort direction applied to `sortBy`. + SortOrder *ListRESTAPIsParamsSortOrder `form:"sortOrder,omitempty" json:"sortOrder,omitempty" yaml:"sortOrder,omitempty"` + + // Query Case-insensitive substring filter matched against the resource id (handle). + Query *QueryQ `form:"query,omitempty" json:"query,omitempty" yaml:"query,omitempty"` } +// ListRESTAPIsParamsSortBy defines parameters for ListRESTAPIs. +type ListRESTAPIsParamsSortBy string + +// ListRESTAPIsParamsSortOrder defines parameters for ListRESTAPIs. +type ListRESTAPIsParamsSortOrder string + // GetDeploymentsParams defines parameters for GetDeployments. type GetDeploymentsParams struct { // GatewayId **Gateway ID** consisting of the **UUID** of the Gateway to filter status by. @@ -2789,6 +3057,12 @@ type GetDeploymentsParams struct { // Status Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) Status *GetDeploymentsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` + + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // GetDeploymentsParamsStatus defines parameters for GetDeployments. @@ -2806,18 +3080,39 @@ type UndeployDeploymentParams struct { GatewayId string `form:"gatewayId" json:"gatewayId" yaml:"gatewayId"` } +// GetRESTAPIGatewaysParams defines parameters for GetRESTAPIGateways. +type GetRESTAPIGatewaysParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + // AddGatewaysToAPIJSONBody defines parameters for AddGatewaysToAPI. type AddGatewaysToAPIJSONBody = []AddGatewayToRESTAPIRequest // ListSecretsParams defines parameters for ListSecrets. type ListSecretsParams struct { - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` // UpdatedAfter RFC3339 timestamp — return only secrets updated after this time. Used by GW controller for incremental polling. UpdatedAfter *time.Time `form:"updatedAfter,omitempty" json:"updatedAfter,omitempty" yaml:"updatedAfter,omitempty"` } +// ListSubscriptionPlansParams defines parameters for ListSubscriptionPlans. +type ListSubscriptionPlansParams struct { + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` +} + // ListSubscriptionsParams defines parameters for ListSubscriptions. type ListSubscriptionsParams struct { // ApiId Filter by API ID (UUID or handle) @@ -2832,11 +3127,11 @@ type ListSubscriptionsParams struct { // Status Filter by status (ACTIVE, INACTIVE, REVOKED) Status *ListSubscriptionsParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` - // Limit Maximum number of subscriptions to return - Limit *int `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` + // Limit Maximum number of items to return per page. + Limit *LimitQ `form:"limit,omitempty" json:"limit,omitempty" yaml:"limit,omitempty"` - // Offset Number of subscriptions to skip - Offset *int `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` + // Offset Zero-based index of the first item to return. + Offset *OffsetQ `form:"offset,omitempty" json:"offset,omitempty" yaml:"offset,omitempty"` } // ListSubscriptionsParamsStatus defines parameters for ListSubscriptions. diff --git a/platform-api/internal/dto/secret.go b/platform-api/internal/dto/secret.go index ac1aef88e4..5406a90bce 100644 --- a/platform-api/internal/dto/secret.go +++ b/platform-api/internal/dto/secret.go @@ -63,6 +63,7 @@ type SecretSummary struct { // SecretListResponse wraps the paginated list of secrets. type SecretListResponse struct { + Count int `json:"count"` List []*SecretSummary `json:"list"` Pagination Pagination `json:"pagination"` } diff --git a/platform-api/internal/handler/api.go b/platform-api/internal/handler/api.go index 164ba62f12..e2f8c5658e 100644 --- a/platform-api/internal/handler/api.go +++ b/platform-api/internal/handler/api.go @@ -183,7 +183,9 @@ func (h *APIHandler) ListAPIs(w http.ResponseWriter, r *http.Request) error { return apperror.ValidationFailed.New("projectId query parameter is required") } - apis, err := h.apiService.GetAPIsByOrganization(orgId, projectId) + opts := parseListOptions(r) + + apis, total, err := h.apiService.GetAPIsByOrganization(orgId, projectId, opts) if err != nil { if errors.Is(err, constants.ErrProjectNotFound) { return apperror.ProjectNotFound.Wrap(err). @@ -197,9 +199,9 @@ func (h *APIHandler) ListAPIs(w http.ResponseWriter, r *http.Request) error { Count: len(apis), List: apis, Pagination: api.Pagination{ - Total: len(apis), - Offset: 0, - Limit: len(apis), + Total: total, + Offset: opts.Offset, + Limit: opts.Limit, }, } @@ -366,7 +368,9 @@ func (h *APIHandler) GetAPIGateways(w http.ResponseWriter, r *http.Request) erro return apperror.ValidationFailed.New("API ID is required") } - gatewaysResponse, err := h.apiService.GetAPIGatewaysByHandle(apiId, orgId) + limit, offset := parsePagination(r) + + gatewaysResponse, err := h.apiService.GetAPIGatewaysByHandle(apiId, orgId, limit, offset) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { return apperror.RESTAPINotFound.Wrap(err). diff --git a/platform-api/internal/handler/api_deployment.go b/platform-api/internal/handler/api_deployment.go index 056b448325..f006546eb0 100644 --- a/platform-api/internal/handler/api_deployment.go +++ b/platform-api/internal/handler/api_deployment.go @@ -342,6 +342,8 @@ func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Reques status = string(*params.Status) } + limit, offset := parsePagination(r) + deployments, err := h.deploymentService.GetDeploymentsByHandle(apiId, gatewayId, status, orgId) if err != nil { if errors.Is(err, constants.ErrAPINotFound) { @@ -354,6 +356,7 @@ func (h *DeploymentHandler) GetDeployments(w http.ResponseWriter, r *http.Reques WithLogMessage(fmt.Sprintf("failed to get deployments for API %s", apiId)) } + paginateDeploymentList(deployments, limit, offset) httputil.WriteJSON(w, http.StatusOK, deployments) return nil } diff --git a/platform-api/internal/handler/apikey_user.go b/platform-api/internal/handler/apikey_user.go index 2f84529f35..daa66f2a06 100644 --- a/platform-api/internal/handler/apikey_user.go +++ b/platform-api/internal/handler/apikey_user.go @@ -64,7 +64,9 @@ func (h *APIKeyUserHandler) ListUserAPIKeys(w http.ResponseWriter, r *http.Reque types = strings.Split(typeParam, ",") } - response, err := h.apiKeyUserService.ListAPIKeysByUser(r.Context(), orgID, callerUserID, types) + limit, offset := parsePagination(r) + + response, err := h.apiKeyUserService.ListAPIKeysByUser(r.Context(), orgID, callerUserID, types, limit, offset) if err != nil { return apperror.Internal.Wrap(err). WithLogMessage("failed to list API keys for user in org " + orgID) diff --git a/platform-api/internal/handler/application.go b/platform-api/internal/handler/application.go index 23b5494efd..f7ead98861 100644 --- a/platform-api/internal/handler/application.go +++ b/platform-api/internal/handler/application.go @@ -23,7 +23,6 @@ import ( "fmt" "log/slog" "net/http" - "strconv" "strings" "github.com/wso2/api-platform/platform-api/api" @@ -116,33 +115,9 @@ func (h *ApplicationHandler) ListApplications(w http.ResponseWriter, r *http.Req return apperror.ValidationFailed.New("Project ID is required") } - var limitStr string - if v := r.URL.Query().Get("limit"); v != "" { - limitStr = v - } else { - limitStr = "20" - } - var offsetStr string - if v := r.URL.Query().Get("offset"); v != "" { - offsetStr = v - } else { - offsetStr = "0" - } - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + opts := parseListOptions(r) - apps, err := h.applicationService.GetApplicationsByOrganization(orgID, projectID, limit, offset) + apps, err := h.applicationService.GetApplicationsByOrganization(orgID, projectID, opts) if err != nil { return h.mapApplicationError(err). WithLogMessage(fmt.Sprintf("failed to list applications for project %s in org %s", projectID, orgID)) @@ -220,24 +195,7 @@ func (h *ApplicationHandler) ListApplicationAssociations(w http.ResponseWriter, return apperror.ValidationFailed.New("Application ID is required") } - limit := 20 - if limitStr := strings.TrimSpace(r.URL.Query().Get("limit")); limitStr != "" { - parsedLimit, err := strconv.Atoi(limitStr) - if err == nil && parsedLimit > 0 { - if parsedLimit > 100 { - parsedLimit = 100 - } - limit = parsedLimit - } - } - - offset := 0 - if offsetStr := strings.TrimSpace(r.URL.Query().Get("offset")); offsetStr != "" { - parsedOffset, err := strconv.Atoi(offsetStr) - if err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } + limit, offset := parsePagination(r) associations, err := h.applicationService.ListApplicationAssociations(appID, orgID, limit, offset) if err != nil { @@ -316,24 +274,7 @@ func (h *ApplicationHandler) ListApplicationAPIKeys(w http.ResponseWriter, r *ht return apperror.ValidationFailed.New("Application ID is required") } - limit := 20 - if limitStr := strings.TrimSpace(r.URL.Query().Get("limit")); limitStr != "" { - parsedLimit, err := strconv.Atoi(limitStr) - if err == nil && parsedLimit > 0 { - if parsedLimit > 100 { - parsedLimit = 100 - } - limit = parsedLimit - } - } - - offset := 0 - if offsetStr := strings.TrimSpace(r.URL.Query().Get("offset")); offsetStr != "" { - parsedOffset, err := strconv.Atoi(offsetStr) - if err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } + limit, offset := parsePagination(r) keys, err := h.applicationService.ListMappedAPIKeys(appID, orgID, limit, offset) if err != nil { @@ -361,24 +302,7 @@ func (h *ApplicationHandler) ListApplicationAssociationAPIKeys(w http.ResponseWr return apperror.ValidationFailed.New("Association ID is required") } - limit := 20 - if limitStr := strings.TrimSpace(r.URL.Query().Get("limit")); limitStr != "" { - parsedLimit, err := strconv.Atoi(limitStr) - if err == nil && parsedLimit > 0 { - if parsedLimit > 100 { - parsedLimit = 100 - } - limit = parsedLimit - } - } - - offset := 0 - if offsetStr := strings.TrimSpace(r.URL.Query().Get("offset")); offsetStr != "" { - parsedOffset, err := strconv.Atoi(offsetStr) - if err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } + limit, offset := parsePagination(r) keys, err := h.applicationService.ListMappedAPIKeysForAssociation(appID, associationID, orgID, limit, offset) if err != nil { diff --git a/platform-api/internal/handler/gateway.go b/platform-api/internal/handler/gateway.go index db5c879097..f2f007be10 100644 --- a/platform-api/internal/handler/gateway.go +++ b/platform-api/internal/handler/gateway.go @@ -135,7 +135,9 @@ func (h *GatewayHandler) ListGateways(w http.ResponseWriter, r *http.Request) er return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } - gateways, err := h.gatewayService.ListGateways(&organizationID) + opts := parseListOptions(r) + + gateways, err := h.gatewayService.ListGateways(&organizationID, opts) if err != nil { return apperror.Internal.Wrap(err).WithLogMessage("failed to list gateways") } @@ -293,7 +295,9 @@ func (h *GatewayHandler) ListTokens(w http.ResponseWriter, r *http.Request) erro return apperror.ValidationFailed.New("Gateway ID is required") } - tokens, err := h.gatewayService.ListTokens(gatewayId, orgId) + limit, offset := parsePagination(r) + + tokens, err := h.gatewayService.ListTokens(gatewayId, orgId, limit, offset) if err != nil { var appErr *apperror.Error if errors.As(err, &appErr) { @@ -500,12 +504,22 @@ func (h *GatewayHandler) ListCustomPolicies(w http.ResponseWriter, r *http.Reque return apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") } - policies, err := h.gatewayService.ListCustomPolicies(orgId) + limit, offset := parsePagination(r) + + policies, total, err := h.gatewayService.ListCustomPolicies(orgId, limit, offset) if err != nil { return apperror.Internal.Wrap(err).WithLogMessage(fmt.Sprintf("failed to list custom policies, orgID=%s", orgId)) } - httputil.WriteJSON(w, http.StatusOK, policies) + httputil.WriteJSON(w, http.StatusOK, map[string]any{ + "count": len(policies), + "list": policies, + "pagination": api.Pagination{ + Total: total, + Offset: offset, + Limit: limit, + }, + }) return nil } diff --git a/platform-api/internal/handler/llm.go b/platform-api/internal/handler/llm.go index d341d17318..071c78f051 100644 --- a/platform-api/internal/handler/llm.go +++ b/platform-api/internal/handler/llm.go @@ -24,7 +24,6 @@ import ( "io" "log/slog" "net/http" - "strconv" "strings" "github.com/wso2/api-platform/platform-api/api" @@ -208,27 +207,7 @@ func (h *LLMHandler) ListLLMProviderTemplates(w http.ResponseWriter, r *http.Req WithLogMessage("organization claim not found in token") } - limitStr := r.URL.Query().Get("limit") - if limitStr == "" { - limitStr = "20" - } - offsetStr := r.URL.Query().Get("offset") - if offsetStr == "" { - offsetStr = "0" - } - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + limit, offset := parsePagination(r) q, familyScoped := parseTemplateQuery(r.URL.Query().Get("query")) if familyScoped { @@ -464,27 +443,7 @@ func (h *LLMHandler) ListLLMProviders(w http.ResponseWriter, r *http.Request) er WithLogMessage("organization claim not found in token") } - limitStr := r.URL.Query().Get("limit") - if limitStr == "" { - limitStr = "20" - } - offsetStr := r.URL.Query().Get("offset") - if offsetStr == "" { - offsetStr = "0" - } - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + limit, offset := parsePagination(r) resp, err := h.providerService.List(orgID, limit, offset) if err != nil { @@ -654,27 +613,7 @@ func (h *LLMHandler) ListLLMProxies(w http.ResponseWriter, r *http.Request) erro return apperror.ValidationFailed.New("projectId query parameter is required") } - limitStr := r.URL.Query().Get("limit") - if limitStr == "" { - limitStr = "20" - } - offsetStr := r.URL.Query().Get("offset") - if offsetStr == "" { - offsetStr = "0" - } - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + limit, offset := parsePagination(r) resp, err := h.proxyService.List(orgID, &projectID, limit, offset) if err != nil { @@ -696,27 +635,7 @@ func (h *LLMHandler) ListLLMProxiesByProvider(w http.ResponseWriter, r *http.Req } providerID := r.PathValue("llmProviderId") - limitStr := r.URL.Query().Get("limit") - if limitStr == "" { - limitStr = "20" - } - offsetStr := r.URL.Query().Get("offset") - if offsetStr == "" { - offsetStr = "0" - } - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + limit, offset := parsePagination(r) resp, err := h.proxyService.ListByProvider(orgID, providerID, limit, offset) if err != nil { diff --git a/platform-api/internal/handler/llm_apikey.go b/platform-api/internal/handler/llm_apikey.go index c85261b867..a9dcfb1bc4 100644 --- a/platform-api/internal/handler/llm_apikey.go +++ b/platform-api/internal/handler/llm_apikey.go @@ -68,7 +68,9 @@ func (h *LLMProviderAPIKeyHandler) ListAPIKeys(w http.ResponseWriter, r *http.Re return err } - response, err := h.apiKeyService.ListLLMProviderAPIKeys(r.Context(), providerID, orgID, callerUserID) + limit, offset := parsePagination(r) + + response, err := h.apiKeyService.ListLLMProviderAPIKeys(r.Context(), providerID, orgID, callerUserID, limit, offset) if err != nil { var appErr *apperror.Error if errors.As(err, &appErr) { diff --git a/platform-api/internal/handler/llm_deployment.go b/platform-api/internal/handler/llm_deployment.go index c062684c9b..acd1439624 100644 --- a/platform-api/internal/handler/llm_deployment.go +++ b/platform-api/internal/handler/llm_deployment.go @@ -296,6 +296,8 @@ func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployments(w http.Response status = &v } + limit, offset := parsePagination(r) + deployments, err := h.deploymentService.GetLLMProviderDeployments(providerId, orgId, gatewayId, status) if err != nil { switch { @@ -309,6 +311,7 @@ func (h *LLMProviderDeploymentHandler) GetLLMProviderDeployments(w http.Response } } + paginateDeploymentList(deployments, limit, offset) httputil.WriteJSON(w, http.StatusOK, deployments) return nil } @@ -562,6 +565,8 @@ func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployments(w http.ResponseWriter status = &v } + limit, offset := parsePagination(r) + deployments, err := h.deploymentService.GetLLMProxyDeployments(proxyId, orgId, gatewayId, status) if err != nil { switch { @@ -575,6 +580,7 @@ func (h *LLMProxyDeploymentHandler) GetLLMProxyDeployments(w http.ResponseWriter } } + paginateDeploymentList(deployments, limit, offset) httputil.WriteJSON(w, http.StatusOK, deployments) return nil } diff --git a/platform-api/internal/handler/llm_proxy_apikey.go b/platform-api/internal/handler/llm_proxy_apikey.go index ccb85e8a61..f132d59e7e 100644 --- a/platform-api/internal/handler/llm_proxy_apikey.go +++ b/platform-api/internal/handler/llm_proxy_apikey.go @@ -68,7 +68,9 @@ func (h *LLMProxyAPIKeyHandler) ListAPIKeys(w http.ResponseWriter, r *http.Reque return err } - response, err := h.apiKeyService.ListLLMProxyAPIKeys(r.Context(), proxyID, orgID, callerUserID) + limit, offset := parsePagination(r) + + response, err := h.apiKeyService.ListLLMProxyAPIKeys(r.Context(), proxyID, orgID, callerUserID, limit, offset) if err != nil { var appErr *apperror.Error if errors.As(err, &appErr) { diff --git a/platform-api/internal/handler/mcp.go b/platform-api/internal/handler/mcp.go index 35f85aa442..ecb8671145 100644 --- a/platform-api/internal/handler/mcp.go +++ b/platform-api/internal/handler/mcp.go @@ -23,7 +23,6 @@ import ( "fmt" "log/slog" "net/http" - "strconv" "strings" "github.com/wso2/api-platform/platform-api/api" @@ -105,29 +104,7 @@ func (h *MCPProxyHandler) ListMCPProxies(w http.ResponseWriter, r *http.Request) return apperror.ValidationFailed.New("projectId query parameter is required") } - limitStr := r.URL.Query().Get("limit") - if limitStr == "" { - limitStr = "20" - } - offsetStr := r.URL.Query().Get("offset") - if offsetStr == "" { - offsetStr = "0" - } - - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - h.slogger.Warn("Invalid limit query parameter, defaulting to 20", "input", limitStr) - limit = 20 - } - if limit > 100 { - h.slogger.Warn("Limit query parameter exceeds maximum, capping to 100", "input", limit) - limit = 100 - } - - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + limit, offset := parsePagination(r) resp, err := h.service.ListByProject(orgID, projectID, limit, offset) if err != nil { diff --git a/platform-api/internal/handler/mcp_deployment.go b/platform-api/internal/handler/mcp_deployment.go index b44e2d80d2..4371b4df11 100644 --- a/platform-api/internal/handler/mcp_deployment.go +++ b/platform-api/internal/handler/mcp_deployment.go @@ -329,6 +329,8 @@ func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployments(w http.ResponseWriter statusVal = string(*params.Status) } + limit, offset := parsePagination(r) + deployments, err := h.deploymentService.GetDeploymentsByHandle(proxyId, gatewayVal, statusVal, orgId) if err != nil { switch { @@ -342,6 +344,7 @@ func (h *MCPProxyDeploymentHandler) GetMCPProxyDeployments(w http.ResponseWriter } } + paginateDeploymentList(deployments, limit, offset) httputil.WriteJSON(w, http.StatusOK, deployments) return nil } diff --git a/platform-api/internal/handler/organization.go b/platform-api/internal/handler/organization.go index 2bd46ec805..aeeac1c7ff 100644 --- a/platform-api/internal/handler/organization.go +++ b/platform-api/internal/handler/organization.go @@ -22,8 +22,6 @@ import ( "errors" "log/slog" "net/http" - "strconv" - "strings" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" @@ -164,22 +162,7 @@ func (h *OrganizationHandler) GetOrganizationByID(w http.ResponseWriter, r *http // ListOrganizations handles GET /api/v0.9/organizations func (h *OrganizationHandler) ListOrganizations(w http.ResponseWriter, r *http.Request) error { - limit := 20 - if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - limit = parsed - } - } - if limit > 100 { - limit = 100 - } - - offset := 0 - if v := strings.TrimSpace(r.URL.Query().Get("offset")); v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - offset = parsed - } - } + limit, offset := parsePagination(r) orgs, total, err := h.orgService.ListOrganizations(limit, offset) if err != nil { diff --git a/platform-api/internal/handler/pagination.go b/platform-api/internal/handler/pagination.go new file mode 100644 index 0000000000..abcb3eea3f --- /dev/null +++ b/platform-api/internal/handler/pagination.go @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025, 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 ( + "net/http" + "strconv" + "strings" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/internal/pagination" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// Pagination bounds mirror the limit-Q / offset-Q parameter contract in the +// OpenAPI spec (resources/openapi.yaml): limit is 1..100 with a default of 20, +// offset is >= 0 with a default of 0. +const ( + defaultPageLimit = 20 + maxPageLimit = 100 + minPageLimit = 1 + defaultPageOffset = 0 +) + +// parsePagination extracts and normalizes the `limit` and `offset` query +// parameters according to the spec contract. A missing or malformed value falls +// back to the documented default; a well-formed but out-of-range value is +// clamped into the documented window on either side. Neither errors, so a client +// can never push the API outside its safe window. +func parsePagination(r *http.Request) (limit, offset int) { + limit = defaultPageLimit + if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + limit = min(max(parsed, minPageLimit), maxPageLimit) + } + } + + offset = defaultPageOffset + if v := strings.TrimSpace(r.URL.Query().Get("offset")); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 { + offset = parsed + } + } + + return limit, offset +} + +// parseListOptions extends parsePagination with the standard sorting and search +// query parameters (sortBy, sortOrder, query) documented for collection GETs. +// +// Only sortOrder is normalized here (to "asc"/"desc", defaulting to "desc"); +// sortBy is passed through verbatim and resolved against a per-table allowlist at +// the repository layer, so an unrecognized field silently falls back to the +// default sort rather than erroring — matching how out-of-range limit/offset are +// clamped rather than rejected. `query` is a trimmed substring matched +// case-insensitively against the resource handle (id). +func parseListOptions(r *http.Request) repository.ListOptions { + limit, offset := parsePagination(r) + + sortOrder := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sortOrder"))) + if sortOrder != "asc" && sortOrder != "desc" { + sortOrder = "desc" + } + + return repository.ListOptions{ + Limit: limit, + Offset: offset, + SortBy: strings.TrimSpace(r.URL.Query().Get("sortBy")), + SortOrder: sortOrder, + Search: strings.TrimSpace(r.URL.Query().Get("query")), + } +} + +// pageWindow windows a fully-materialized, bounded collection in the handler +// before responding. +func pageWindow[T any](items []T, limit, offset int) []T { + return pagination.Window(items, limit, offset) +} + +// paginateDeploymentList windows a fully-materialized deployment list response +// and stamps its pagination metadata. Deployments are a small, bounded set per +// artifact, so the total reflects the full list and the window is applied in +// memory. +func paginateDeploymentList(resp *api.DeploymentListResponse, limit, offset int) { + if resp == nil { + return + } + total := len(resp.List) + resp.List = pageWindow(resp.List, limit, offset) + resp.Count = len(resp.List) + resp.Pagination = api.Pagination{Total: total, Offset: offset, Limit: limit} +} diff --git a/platform-api/internal/handler/project.go b/platform-api/internal/handler/project.go index fe29f667b8..561d74e5e7 100644 --- a/platform-api/internal/handler/project.go +++ b/platform-api/internal/handler/project.go @@ -119,7 +119,9 @@ func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) er WithLogMessage("organization claim not found in token") } - projects, err := h.projectService.GetProjectsByOrganization(orgID) + opts := parseListOptions(r) + + projects, total, err := h.projectService.GetProjectsByOrganization(orgID, opts) if err != nil { var appErr *apperror.Error if errors.As(err, &appErr) { @@ -134,9 +136,9 @@ func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) er Count: len(projects), List: projects, Pagination: api.Pagination{ - Total: len(projects), - Offset: 0, - Limit: len(projects), + Total: total, + Offset: opts.Offset, + Limit: opts.Limit, }, }) return nil diff --git a/platform-api/internal/handler/secret.go b/platform-api/internal/handler/secret.go index 8ad8feb8f3..4698ff838c 100644 --- a/platform-api/internal/handler/secret.go +++ b/platform-api/internal/handler/secret.go @@ -21,7 +21,6 @@ import ( "errors" "log/slog" "net/http" - "strconv" "time" "github.com/wso2/api-platform/platform-api/internal/apperror" @@ -104,21 +103,7 @@ func (h *SecretHandler) ListSecrets(w http.ResponseWriter, r *http.Request) erro WithLogMessage("organization claim not found in token") } - limit := 25 - offset := 0 - if l := r.URL.Query().Get("limit"); l != "" { - if v, err := strconv.Atoi(l); err == nil && v > 0 { - if v > 100 { - v = 100 - } - limit = v - } - } - if o := r.URL.Query().Get("offset"); o != "" { - if v, err := strconv.Atoi(o); err == nil && v >= 0 { - offset = v - } - } + limit, offset := parsePagination(r) var updatedAfter *time.Time if ua := r.URL.Query().Get("updatedAfter"); ua != "" { diff --git a/platform-api/internal/handler/secret_integration_test.go b/platform-api/internal/handler/secret_integration_test.go index 50f5b15338..78e3fcb9c0 100644 --- a/platform-api/internal/handler/secret_integration_test.go +++ b/platform-api/internal/handler/secret_integration_test.go @@ -220,8 +220,10 @@ func TestSecretHandler_List_ReturnsPaginationObject(t *testing.T) { if _, hasList := resp["list"]; !hasList { t.Error("response should have list field") } - if _, hasCount := resp["count"]; hasCount { - t.Error("response should NOT have top-level count field") + if count, hasCount := resp["count"]; !hasCount { + t.Error("response should have top-level count field") + } else if int(count.(float64)) != 3 { + t.Errorf("expected count=3, got %v", count) } pagination, ok := resp["pagination"].(map[string]interface{}) @@ -232,8 +234,8 @@ func TestSecretHandler_List_ReturnsPaginationObject(t *testing.T) { if int(pagination["total"].(float64)) != 3 { t.Errorf("expected total=3, got %v", pagination["total"]) } - if int(pagination["limit"].(float64)) != 25 { - t.Errorf("expected limit=25, got %v", pagination["limit"]) + if int(pagination["limit"].(float64)) != 20 { + t.Errorf("expected limit=20, got %v", pagination["limit"]) } if int(pagination["offset"].(float64)) != 0 { t.Errorf("expected offset=0, got %v", pagination["offset"]) diff --git a/platform-api/internal/handler/subscription_handler.go b/platform-api/internal/handler/subscription_handler.go index 354298b028..2cdd8c2b5d 100644 --- a/platform-api/internal/handler/subscription_handler.go +++ b/platform-api/internal/handler/subscription_handler.go @@ -23,7 +23,6 @@ import ( "fmt" "log/slog" "net/http" - "strconv" "strings" api "github.com/wso2/api-platform/platform-api/api" @@ -150,29 +149,7 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R } statusPtr = &status } - var limitStr string - if v := r.URL.Query().Get("limit"); v != "" { - limitStr = v - } else { - limitStr = "20" - } - var offsetStr string - if v := r.URL.Query().Get("offset"); v != "" { - offsetStr = v - } else { - offsetStr = "0" - } - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 - } + limit, offset := parsePagination(r) list, total, err := h.subscriptionService.ListSubscriptionsByFilters(orgId, apiIDPtr, subscriberIDPtr, appIDPtr, statusPtr, limit, offset) if err != nil { var appErr *apperror.Error @@ -226,8 +203,8 @@ func (h *SubscriptionHandler) ListSubscriptions(w http.ResponseWriter, r *http.R items = append(items, h.toSubscriptionResponseWithMaps(sub, orgId, artifactMetaMap, planNameMap, createdByMap)) } httputil.WriteJSON(w, http.StatusOK, map[string]any{ - "subscriptions": items, - "count": len(items), + "list": items, + "count": len(items), "pagination": map[string]any{ "total": total, "offset": offset, diff --git a/platform-api/internal/handler/subscription_plan_handler.go b/platform-api/internal/handler/subscription_plan_handler.go index dc9513369c..93aac5e648 100644 --- a/platform-api/internal/handler/subscription_plan_handler.go +++ b/platform-api/internal/handler/subscription_plan_handler.go @@ -23,7 +23,6 @@ import ( "fmt" "log/slog" "net/http" - "strconv" "time" api "github.com/wso2/api-platform/platform-api/api" @@ -233,28 +232,12 @@ func (h *SubscriptionPlanHandler) ListSubscriptionPlans(w http.ResponseWriter, r WithLogMessage("organization claim not found in token") } - var limitStr string - if v := r.URL.Query().Get("limit"); v != "" { - limitStr = v - } else { - limitStr = "20" - } - var offsetStr string - if v := r.URL.Query().Get("offset"); v != "" { - offsetStr = v - } else { - offsetStr = "0" - } - limit, err := strconv.Atoi(limitStr) - if err != nil || limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - offset, err := strconv.Atoi(offsetStr) - if err != nil || offset < 0 { - offset = 0 + limit, offset := parsePagination(r) + + total, err := h.planService.CountPlans(orgId) + if err != nil { + return apperror.Internal.Wrap(err). + WithLogMessage(fmt.Sprintf("failed to count subscription plans for org %s", orgId)) } list, err := h.planService.ListPlans(orgId, limit, offset) @@ -271,7 +254,15 @@ func (h *SubscriptionPlanHandler) ListSubscriptionPlans(w http.ResponseWriter, r } items = append(items, item) } - httputil.WriteJSON(w, http.StatusOK, map[string]any{"subscriptionPlans": items, "count": len(items)}) + httputil.WriteJSON(w, http.StatusOK, map[string]any{ + "list": items, + "count": len(items), + "pagination": api.Pagination{ + Total: total, + Offset: offset, + Limit: limit, + }, + }) return nil } diff --git a/platform-api/internal/integration/lifecycle_test.go b/platform-api/internal/integration/lifecycle_test.go index 23a0128fb3..06d8c780bd 100644 --- a/platform-api/internal/integration/lifecycle_test.go +++ b/platform-api/internal/integration/lifecycle_test.go @@ -183,7 +183,7 @@ func TestLifecycle_ProjectPagination(t *testing.T) { seen := map[string]bool{} for offset := 0; offset < n; offset += 2 { - page, err := projectRepo.ListProjects(org.ID, 2, offset) + page, err := projectRepo.ListProjects(org.ID, repository.ListOptions{Limit: 2, Offset: offset}) if err != nil { t.Fatalf("[%s] ListProjects(2,%d) failed: %v", it.driver, offset, err) } diff --git a/platform-api/internal/pagination/pagination.go b/platform-api/internal/pagination/pagination.go new file mode 100644 index 0000000000..b0a7970caf --- /dev/null +++ b/platform-api/internal/pagination/pagination.go @@ -0,0 +1,38 @@ +/* + * 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 pagination holds the in-memory windowing primitive shared by the +// handler and service layers, so the two cannot drift apart. +package pagination + +// Window returns the [offset, offset+limit) slice of items, clamped to the +// bounds of items. It is used to window a fully-materialized, bounded +// collection. An out-of-range offset or a non-positive limit yields an empty +// (non-nil) slice. +func Window[T any](items []T, limit, offset int) []T { + if offset < 0 { + offset = 0 + } + if offset >= len(items) || limit <= 0 { + return items[:0] + } + end := offset + limit + if end > len(items) { + end = len(items) + } + return items[offset:end] +} diff --git a/platform-api/internal/repository/api.go b/platform-api/internal/repository/api.go index 2b04a53f4f..a8978be8a1 100644 --- a/platform-api/internal/repository/api.go +++ b/platform-api/internal/repository/api.go @@ -283,6 +283,71 @@ func (r *APIRepo) GetAPIsByOrganizationUUID(orgUUID string, projectUUID string) return apis, rows.Err() } +// GetAPIsByOrganizationUUIDPaginated retrieves a single page of APIs for an +// organization with an optional project filter, applying LIMIT/OFFSET at the +// database via the dialect-aware pagination clause. +func (r *APIRepo) GetAPIsByOrganizationUUIDPaginated(orgUUID, projectUUID string, opts ListOptions) ([]*model.API, error) { + query := ` + SELECT uuid, handle, display_name, description, version, created_by, updated_by, + project_uuid, organization_uuid, lifecycle_status, configuration, origin, created_at, updated_at + FROM rest_apis + WHERE organization_uuid = ?` + args := []interface{}{orgUUID} + + if projectUUID != "" { + query += " AND project_uuid = ?" + args = append(args, projectUUID) + } + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + query += " ORDER BY " + col + " " + dir + ", handle ASC" + + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + query += " " + pageClause + args = append(args, pageArgs...) + + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var apis []*model.API + for rows.Next() { + api, err := r.scanAPI(rows) + if err != nil { + return nil, err + } + apis = append(apis, api) + } + + return apis, rows.Err() +} + +// CountAPIsByOrganizationUUID returns the total number of APIs for an +// organization (with an optional project filter), independent of pagination. +func (r *APIRepo) CountAPIsByOrganizationUUID(orgUUID, projectUUID, search string) (int, error) { + query := `SELECT COUNT(*) FROM rest_apis WHERE organization_uuid = ?` + args := []interface{}{orgUUID} + if projectUUID != "" { + query += " AND project_uuid = ?" + args = append(args, projectUUID) + } + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + + var total int + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + // GetAPIsByGatewayUUID retrieves all APIs associated with a specific gateway func (r *APIRepo) GetAPIsByGatewayUUID(gatewayUUID, orgUUID string) ([]*model.API, error) { query := ` diff --git a/platform-api/internal/repository/application.go b/platform-api/internal/repository/application.go index 5afb2b3614..5ffb0642eb 100644 --- a/platform-api/internal/repository/application.go +++ b/platform-api/internal/repository/application.go @@ -234,15 +234,21 @@ func (r *ApplicationRepo) GetApplicationsByOrganizationID(orgID string) ([]*mode return scanApplications(rows) } -func (r *ApplicationRepo) GetApplicationsByProjectIDPaginated(projectID, orgID string, _, _ int) ([]*model.Application, error) { - // TODO: Re-enable DB-level pagination when query placeholders and syntax are verified - // across all supported database drivers. - rows, err := r.db.Query(r.db.Rebind(` +func (r *ApplicationRepo) GetApplicationsByProjectIDPaginated(projectID, orgID string, opts ListOptions) ([]*model.Application, error) { + query := ` SELECT uuid, handle, project_uuid, organization_uuid, created_by, updated_by, display_name, description, type, created_at, updated_at FROM applications - WHERE project_uuid = ? AND organization_uuid = ? - ORDER BY created_at DESC, display_name ASC - `), projectID, orgID) + WHERE project_uuid = ? AND organization_uuid = ?` + args := []any{projectID, orgID} + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + query += " ORDER BY " + col + " " + dir + ", handle ASC " + pageClause + args = append(args, pageArgs...) + rows, err := r.db.Query(r.db.Rebind(query), args...) if err != nil { return nil, err } @@ -251,15 +257,15 @@ func (r *ApplicationRepo) GetApplicationsByProjectIDPaginated(projectID, orgID s return scanApplications(rows) } -func (r *ApplicationRepo) GetApplicationsByOrganizationIDPaginated(orgID string, _, _ int) ([]*model.Application, error) { - // TODO: Re-enable DB-level pagination when query placeholders and syntax are verified - // across all supported database drivers. - rows, err := r.db.Query(r.db.Rebind(` +func (r *ApplicationRepo) GetApplicationsByOrganizationIDPaginated(orgID string, limit, offset int) ([]*model.Application, error) { + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + query := ` SELECT uuid, handle, project_uuid, organization_uuid, created_by, updated_by, display_name, description, type, created_at, updated_at FROM applications WHERE organization_uuid = ? ORDER BY created_at DESC, display_name ASC - `), orgID) + ` + pageClause + rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgID}, pageArgs...)...) if err != nil { return nil, err } @@ -268,14 +274,18 @@ func (r *ApplicationRepo) GetApplicationsByOrganizationIDPaginated(orgID string, return scanApplications(rows) } -func (r *ApplicationRepo) CountApplicationsByProjectID(projectID, orgID string) (int, error) { - var count int - err := r.db.QueryRow(r.db.Rebind(` +func (r *ApplicationRepo) CountApplicationsByProjectID(projectID, orgID, search string) (int, error) { + query := ` SELECT COUNT(*) FROM applications - WHERE project_uuid = ? AND organization_uuid = ? - `), projectID, orgID).Scan(&count) - if err != nil { + WHERE project_uuid = ? AND organization_uuid = ?` + args := []any{projectID, orgID} + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + var count int + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&count); err != nil { return 0, err } diff --git a/platform-api/internal/repository/gateway.go b/platform-api/internal/repository/gateway.go index 0b0f7856a3..60bbebfed5 100644 --- a/platform-api/internal/repository/gateway.go +++ b/platform-api/internal/repository/gateway.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/wso2/api-platform/platform-api/internal/constants" @@ -236,6 +237,77 @@ func (r *GatewayRepo) List() ([]*model.Gateway, error) { return aggregateGatewayJoinRows(rows) } +// ListPaginated returns a single page of gateways. Pagination is applied to the +// gateways table in an inner query BEFORE the endpoint join, so LIMIT/OFFSET +// count distinct gateways rather than joined endpoint rows. When orgID is empty +// all organizations are included. Endpoints for the selected page are then +// aggregated by aggregateGatewayJoinRows. +func (r *GatewayRepo) ListPaginated(orgID string, opts ListOptions) ([]*model.Gateway, error) { + inner := `SELECT uuid FROM gateways` + var args []interface{} + var conditions []string + if orgID != "" { + conditions = append(conditions, `organization_uuid = ?`) + args = append(args, orgID) + } + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + // Trim the leading " AND " — this is the first/only place the clause is joined. + conditions = append(conditions, strings.TrimPrefix(searchClause, " AND ")) + args = append(args, searchArgs...) + } + if len(conditions) > 0 { + inner += ` WHERE ` + strings.Join(conditions, ` AND `) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + inner += ` ORDER BY ` + col + ` ` + dir + `, handle ASC` + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + inner += ` ` + pageClause + args = append(args, pageArgs...) + + // The outer ORDER BY must mirror the inner one so the paged gateways retain + // their order after the endpoint join; ge.id ASC keeps endpoint rows stable + // within each gateway for aggregateGatewayJoinRows. + query := fmt.Sprintf(` + SELECT %s + FROM gateways g + LEFT JOIN gateway_endpoints ge ON ge.gateway_uuid = g.uuid + WHERE g.uuid IN (%s) + ORDER BY g.%s %s, g.handle ASC, ge.id ASC + `, gatewaySelectColumns, inner, col, dir) + + rows, err := r.db.Query(r.db.Rebind(query), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + return aggregateGatewayJoinRows(rows) +} + +// CountGateways returns the total number of gateways, optionally scoped to an +// organization (empty orgID counts all), independent of any pagination. +func (r *GatewayRepo) CountGateways(orgID, search string) (int, error) { + query := `SELECT COUNT(*) FROM gateways` + var args []interface{} + var conditions []string + if orgID != "" { + conditions = append(conditions, `organization_uuid = ?`) + args = append(args, orgID) + } + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + conditions = append(conditions, strings.TrimPrefix(searchClause, " AND ")) + args = append(args, searchArgs...) + } + if len(conditions) > 0 { + query += ` WHERE ` + strings.Join(conditions, ` AND `) + } + var total int + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + // Delete removes a gateway with organization isolation and cleans up all associations func (r *GatewayRepo) Delete(gatewayID, organizationID string) error { // Start transaction for atomicity diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index d389a73024..a757169a65 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -46,7 +46,8 @@ type ProjectRepository interface { GetProjectsByOrganizationID(orgID string) ([]*model.Project, error) UpdateProject(project *model.Project) error DeleteProject(projectId string) error - ListProjects(orgID string, limit, offset int) ([]*model.Project, error) + ListProjects(orgID string, opts ListOptions) ([]*model.Project, error) + CountProjects(orgID, search string) (int, error) } type ArtifactRepository interface { @@ -74,9 +75,9 @@ type ApplicationRepository interface { GetLLMProxyProjectUUID(artifactUUID, orgID string) (string, error) GetApplicationsByProjectID(projectID, orgID string) ([]*model.Application, error) GetApplicationsByOrganizationID(orgID string) ([]*model.Application, error) - GetApplicationsByProjectIDPaginated(projectID, orgID string, limit, offset int) ([]*model.Application, error) + GetApplicationsByProjectIDPaginated(projectID, orgID string, opts ListOptions) ([]*model.Application, error) GetApplicationsByOrganizationIDPaginated(orgID string, limit, offset int) ([]*model.Application, error) - CountApplicationsByProjectID(projectID, orgID string) (int, error) + CountApplicationsByProjectID(projectID, orgID, search string) (int, error) CountApplicationsByOrganizationID(orgID string) (int, error) GetApplicationByNameInProject(name, projectID, orgID string) (*model.Application, error) CheckApplicationHandleExists(handle, orgID string) (bool, error) @@ -103,6 +104,8 @@ type APIRepository interface { GetAPIMetadataByHandle(handle, orgUUID string) (*model.APIMetadata, error) GetAPIsByProjectUUID(projectUUID, orgUUID string) ([]*model.API, error) GetAPIsByOrganizationUUID(orgUUID string, projectUUID string) ([]*model.API, error) + GetAPIsByOrganizationUUIDPaginated(orgUUID, projectUUID string, opts ListOptions) ([]*model.API, error) + CountAPIsByOrganizationUUID(orgUUID, projectUUID, search string) (int, error) GetAPIsByGatewayUUID(gatewayUUID, orgUUID string) ([]*model.API, error) UpdateAPI(api *model.API) error DeleteAPI(apiUUID, orgUUID string) error @@ -158,6 +161,8 @@ type GatewayRepository interface { GetByOrganizationID(orgID string) ([]*model.Gateway, error) GetByHandleAndOrgID(handle, orgID string) (*model.Gateway, error) List() ([]*model.Gateway, error) + ListPaginated(orgID string, opts ListOptions) ([]*model.Gateway, error) + CountGateways(orgID, search string) (int, error) Delete(gatewayID, organizationID string) error UpdateGateway(gateway *model.Gateway) error UpdateActiveStatus(gatewayId string, isActive bool) error @@ -190,6 +195,7 @@ type SubscriptionPlanRepository interface { GetByIDs(planIDs []string, orgUUID string) (map[string]string, error) GetByHandleAndOrg(handle, orgUUID string) (*model.SubscriptionPlan, error) ListByOrganization(orgUUID string, limit, offset int) ([]*model.SubscriptionPlan, error) + CountByOrganization(orgUUID string) (int, error) Update(plan *model.SubscriptionPlan) error Delete(planID, orgUUID string) error ExistsByHandleAndOrg(handle, orgUUID string) (bool, error) diff --git a/platform-api/internal/repository/pagination.go b/platform-api/internal/repository/pagination.go new file mode 100644 index 0000000000..71eb9df9d2 --- /dev/null +++ b/platform-api/internal/repository/pagination.go @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025, 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 repository + +import "strings" + +// listSortColumns is the allowlist mapping the sort tokens exposed by the +// standard collection list endpoints (rest-apis, projects, applications, +// gateways) to their backing columns. Every one of those tables carries a +// display_name and created_at column, so the mapping is shared. A resource that +// needs a different set of sortable fields should define its own allowlist +// rather than widening this one. +var listSortColumns = map[string]string{ + "name": "display_name", + "createdAt": "created_at", +} + +// ListOptions carries the pagination, sorting, and search inputs shared by +// collection list queries. Limit/Offset are already normalized by the handler +// layer; SortBy/SortOrder/Search are raw client tokens that MUST be resolved +// through resolveSort / handleSearchClause before touching SQL — they are never +// interpolated into a query verbatim. +type ListOptions struct { + Limit int + Offset int + SortBy string // API sort token (e.g. "name", "createdAt"); "" = default + SortOrder string // "asc" or "desc"; anything else defaults to descending + Search string // case-insensitive handle substring; "" = no filter +} + +// resolveSort maps the client SortBy token to a real column name via the +// per-table allowlist, falling back to defaultColumn when the token is empty or +// unrecognized, and normalizes the direction to ASC/DESC (default DESC). +// +// Both return values originate exclusively from server-controlled inputs (the +// allowlist's values and the two direction constants) — never from the raw +// client string — so they are safe to embed directly into an ORDER BY clause, +// which cannot be parameterized with a bind argument. +func (o ListOptions) resolveSort(allowed map[string]string, defaultColumn string) (column, direction string) { + column = defaultColumn + if c, ok := allowed[o.SortBy]; ok && c != "" { + column = c + } + direction = "DESC" + if strings.EqualFold(o.SortOrder, "asc") { + direction = "ASC" + } + return column, direction +} + +// handleSearchClause returns a SQL fragment (with a leading " AND ") and its +// single bound argument for a case-insensitive substring match against the +// `handle` column, or ("", nil) when search is empty. LIKE metacharacters in the +// input are escaped via ESCAPE '\' so a literal '%' or '_' matches itself rather +// than acting as a wildcard. The escape clause is standard SQL honored by +// SQLite, PostgreSQL, and SQL Server alike. +func handleSearchClause(search string) (string, []any) { + s := strings.TrimSpace(search) + if s == "" { + return "", nil + } + esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.ToLower(s)) + return ` AND LOWER(handle) LIKE ? ESCAPE '\'`, []any{"%" + esc + "%"} +} diff --git a/platform-api/internal/repository/pagination_test.go b/platform-api/internal/repository/pagination_test.go new file mode 100644 index 0000000000..d4fcc39ac7 --- /dev/null +++ b/platform-api/internal/repository/pagination_test.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, 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 repository + +import "testing" + +// TestResolveSort verifies that only allowlisted sort tokens map to columns and +// that everything else — including attempted SQL injection via the sort token — +// falls back to the default column, and that the direction is constrained to the +// two safe constants. This is what makes it safe to interpolate the results into +// an ORDER BY clause (which cannot be a bind parameter). +func TestResolveSort(t *testing.T) { + allowed := map[string]string{"name": "display_name", "createdAt": "created_at"} + + tests := []struct { + name string + sortBy string + sortOrder string + wantColumn string + wantDirection string + }{ + {"known field asc", "name", "asc", "display_name", "ASC"}, + {"known field desc", "createdAt", "desc", "created_at", "DESC"}, + {"empty falls back to default column and desc", "", "", "created_at", "DESC"}, + {"unknown field falls back to default", "password", "asc", "created_at", "ASC"}, + {"injection attempt falls back to default", "created_at; DROP TABLE rest_apis", "asc", "created_at", "ASC"}, + {"direction is case-insensitive", "name", "ASC", "display_name", "ASC"}, + {"garbage direction defaults to desc", "name", "sideways", "display_name", "DESC"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := ListOptions{SortBy: tt.sortBy, SortOrder: tt.sortOrder} + col, dir := opts.resolveSort(allowed, "created_at") + if col != tt.wantColumn { + t.Errorf("column = %q, want %q", col, tt.wantColumn) + } + if dir != tt.wantDirection { + t.Errorf("direction = %q, want %q", dir, tt.wantDirection) + } + }) + } +} + +// TestHandleSearchClause verifies the empty-input short circuit and that LIKE +// metacharacters supplied by the client are escaped so they match literally +// rather than acting as wildcards. +func TestHandleSearchClause(t *testing.T) { + t.Run("empty search yields no clause", func(t *testing.T) { + clause, args := handleSearchClause(" ") + if clause != "" || args != nil { + t.Fatalf("expected empty clause and nil args, got %q / %v", clause, args) + } + }) + + t.Run("plain term is lowercased and wrapped", func(t *testing.T) { + clause, args := handleSearchClause("Payment") + if clause == "" { + t.Fatal("expected a non-empty clause") + } + if len(args) != 1 || args[0] != "%payment%" { + t.Fatalf("args = %v, want [%%payment%%]", args) + } + }) + + t.Run("wildcards are escaped", func(t *testing.T) { + _, args := handleSearchClause("a_b%c") + if len(args) != 1 || args[0] != `%a\_b\%c%` { + t.Fatalf("args = %v, want [%%a\\_b\\%%c%%]", args) + } + }) + + t.Run("backslash is escaped before the metacharacters", func(t *testing.T) { + _, args := handleSearchClause(`a\b`) + if len(args) != 1 || args[0] != `%a\\b%` { + t.Fatalf("args = %v, want [%%a\\\\b%%]", args) + } + }) +} diff --git a/platform-api/internal/repository/project.go b/platform-api/internal/repository/project.go index dc0e474441..623870fa83 100644 --- a/platform-api/internal/repository/project.go +++ b/platform-api/internal/repository/project.go @@ -177,15 +177,21 @@ func (r *ProjectRepo) DeleteProject(projectId string) error { } // ListProjects retrieves projects with pagination -func (r *ProjectRepo) ListProjects(orgID string, limit, offset int) ([]*model.Project, error) { - pageClause, pageArgs := r.db.PaginationClause(limit, offset) +func (r *ProjectRepo) ListProjects(orgID string, opts ListOptions) ([]*model.Project, error) { query := ` SELECT uuid, handle, display_name, organization_uuid, description, created_at, updated_at FROM projects - WHERE organization_uuid = ? - ORDER BY created_at DESC - ` + pageClause - rows, err := r.db.Query(r.db.Rebind(query), append([]any{orgID}, pageArgs...)...) + WHERE organization_uuid = ?` + args := []any{orgID} + if searchClause, searchArgs := handleSearchClause(opts.Search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + col, dir := opts.resolveSort(listSortColumns, "created_at") + pageClause, pageArgs := r.db.PaginationClause(opts.Limit, opts.Offset) + query += " ORDER BY " + col + " " + dir + ", handle ASC " + pageClause + args = append(args, pageArgs...) + rows, err := r.db.Query(r.db.Rebind(query), args...) if err != nil { return nil, err } @@ -204,3 +210,19 @@ func (r *ProjectRepo) ListProjects(orgID string, limit, offset int) ([]*model.Pr return projects, rows.Err() } + +// CountProjects returns the total number of projects in an organization, +// independent of any pagination applied by ListProjects. +func (r *ProjectRepo) CountProjects(orgID, search string) (int, error) { + var total int + query := `SELECT COUNT(*) FROM projects WHERE organization_uuid = ?` + args := []any{orgID} + if searchClause, searchArgs := handleSearchClause(search); searchClause != "" { + query += searchClause + args = append(args, searchArgs...) + } + if err := r.db.QueryRow(r.db.Rebind(query), args...).Scan(&total); err != nil { + return 0, err + } + return total, nil +} diff --git a/platform-api/internal/repository/subscription_plan_repository.go b/platform-api/internal/repository/subscription_plan_repository.go index 4a0f0de8d9..9e383d672e 100644 --- a/platform-api/internal/repository/subscription_plan_repository.go +++ b/platform-api/internal/repository/subscription_plan_repository.go @@ -238,6 +238,17 @@ func (r *SubscriptionPlanRepo) ListByOrganization(orgUUID string, limit, offset return list, rows.Err() } +// CountByOrganization returns the total number of subscription plans in an +// organization, independent of any pagination applied by ListByOrganization. +func (r *SubscriptionPlanRepo) CountByOrganization(orgUUID string) (int, error) { + var total int + query := `SELECT COUNT(*) FROM subscription_plans WHERE organization_uuid = ?` + if err := r.db.QueryRow(r.db.Rebind(query), orgUUID).Scan(&total); err != nil { + return 0, fmt.Errorf("failed to count subscription plans: %w", err) + } + return total, nil +} + // Update updates an existing subscription plan func (r *SubscriptionPlanRepo) Update(plan *model.SubscriptionPlan) error { if plan == nil { diff --git a/platform-api/internal/service/api.go b/platform-api/internal/service/api.go index a016cfefc9..6a1b43ca1c 100644 --- a/platform-api/internal/service/api.go +++ b/platform-api/internal/service/api.go @@ -244,30 +244,35 @@ func (s *APIService) getAPIUUIDByHandle(handle, orgUUID string) (string, error) // GetAPIsByOrganization retrieves all APIs for an organization with optional project filter. // projectHandle, when provided, is the project's handle (not UUID). -func (s *APIService) GetAPIsByOrganization(orgUUID string, projectHandle string) ([]api.RESTAPI, error) { +func (s *APIService) GetAPIsByOrganization(orgUUID string, projectHandle string, opts repository.ListOptions) ([]api.RESTAPI, int, error) { projectUUID := "" // If project handle is provided, resolve it and validate that it belongs to the organization if projectHandle != "" { project, err := s.projectRepo.GetProjectByHandleAndOrgID(projectHandle, orgUUID) if err != nil { - return nil, err + return nil, 0, err } if project == nil { - return nil, constants.ErrProjectNotFound + return nil, 0, constants.ErrProjectNotFound } projectUUID = project.ID } - apiModels, err := s.apiRepo.GetAPIsByOrganizationUUID(orgUUID, projectUUID) + total, err := s.apiRepo.CountAPIsByOrganizationUUID(orgUUID, projectUUID, opts.Search) + if err != nil { + return nil, 0, fmt.Errorf("failed to count apis: %w", err) + } + + apiModels, err := s.apiRepo.GetAPIsByOrganizationUUIDPaginated(orgUUID, projectUUID, opts) if err != nil { - return nil, fmt.Errorf("failed to get apis: %w", err) + return nil, 0, fmt.Errorf("failed to get apis: %w", err) } apis := make([]api.RESTAPI, 0) for _, apiModel := range apiModels { apiResponse, err := s.modelToRESTAPI(apiModel) if err != nil { - return nil, err + return nil, 0, err } if apiResponse != nil { // updatedBy is detail-only; omit it from list responses. @@ -275,7 +280,7 @@ func (s *APIService) GetAPIsByOrganization(orgUUID string, projectHandle string) apis = append(apis, *apiResponse) } } - return apis, nil + return apis, total, nil } // UpdateAPI updates an existing API @@ -453,13 +458,46 @@ func (s *APIService) AddGatewaysToAPIByHandle(handle string, gatewayIds []string return s.AddGatewaysToAPI(apiUUID, gatewayIds, orgId) } -// GetAPIGatewaysByHandle retrieves all gateways associated with an API identified by handle -func (s *APIService) GetAPIGatewaysByHandle(handle, orgId string) (*api.RESTAPIGatewayListResponse, error) { +// GetAPIGatewaysByHandle retrieves a page of gateways associated with an API +// identified by handle, applying the requested limit/offset window. +func (s *APIService) GetAPIGatewaysByHandle(handle, orgId string, limit, offset int) (*api.RESTAPIGatewayListResponse, error) { apiUUID, err := s.getAPIUUIDByHandle(handle, orgId) if err != nil { return nil, err } - return s.GetAPIGateways(apiUUID, orgId) + + apiModel, err := s.apiRepo.GetAPIByUUID(apiUUID, orgId) + if err != nil { + return nil, err + } + if apiModel == nil || apiModel.OrganizationID != orgId { + return nil, constants.ErrAPINotFound + } + + gatewayDetails, err := s.apiRepo.GetAPIGatewaysWithDetails(apiUUID, orgId) + if err != nil { + return nil, err + } + org, err := s.orgRepo.GetOrganizationByUUID(orgId) + if err != nil { + return nil, err + } + orgHandle := "" + if org != nil { + orgHandle = org.Handle + } + + // The gateways associated with a single API are a small, bounded set, so the + // requested window is applied in memory while the total reflects the full set. + total := len(gatewayDetails) + page := paginateSlice(gatewayDetails, limit, offset) + + response, err := apiGatewayDetailsToAPIList(page, orgHandle) + if err != nil { + return nil, fmt.Errorf("failed to convert API gateway details: %w", err) + } + response.Pagination = api.Pagination{Total: total, Offset: offset, Limit: limit} + return response, nil } // AddGatewaysToAPI associates multiple gateways with an API diff --git a/platform-api/internal/service/apikey_user.go b/platform-api/internal/service/apikey_user.go index 34e26fa801..752459182d 100644 --- a/platform-api/internal/service/apikey_user.go +++ b/platform-api/internal/service/apikey_user.go @@ -52,6 +52,7 @@ func (s *APIKeyUserService) ListAPIKeysByUser( ctx context.Context, orgID, username string, types []string, + limit, offset int, ) (*api.UserAPIKeyListResponse, error) { keys, err := s.apiKeyRepo.ListAPIKeysByUser(orgID, username, types) @@ -83,8 +84,14 @@ func (s *APIKeyUserService) ListAPIKeysByUser( items = append(items, item) } + // A user's API keys are a small, bounded set, so the total is the full count + // and the window is applied in memory. + total := len(items) + page := paginateSlice(items, limit, offset) + return &api.UserAPIKeyListResponse{ - Items: items, - Count: len(items), + List: page, + Count: len(page), + Pagination: api.Pagination{Total: total, Offset: offset, Limit: limit}, }, nil } diff --git a/platform-api/internal/service/application.go b/platform-api/internal/service/application.go index 7d5c44a342..77ed8c806b 100644 --- a/platform-api/internal/service/application.go +++ b/platform-api/internal/service/application.go @@ -182,7 +182,7 @@ func (s *ApplicationService) GetApplicationByID(appIDOrHandle, orgID string) (*a return s.modelToApplicationResponse(app) } -func (s *ApplicationService) GetApplicationsByOrganization(orgID, projectHandle string, limit, offset int) (*api.ApplicationListResponse, error) { +func (s *ApplicationService) GetApplicationsByOrganization(orgID, projectHandle string, opts repository.ListOptions) (*api.ApplicationListResponse, error) { org, err := s.orgRepo.GetOrganizationByUUID(orgID) if err != nil { return nil, err @@ -191,14 +191,14 @@ func (s *ApplicationService) GetApplicationsByOrganization(orgID, projectHandle return nil, constants.ErrOrganizationNotFound } - if limit <= 0 { - limit = 20 + if opts.Limit <= 0 { + opts.Limit = 20 } - if limit > 100 { - limit = 100 + if opts.Limit > 100 { + opts.Limit = 100 } - if offset < 0 { - offset = 0 + if opts.Offset < 0 { + opts.Offset = 0 } if strings.TrimSpace(projectHandle) == "" { return nil, constants.ErrProjectNotFound @@ -212,35 +212,23 @@ func (s *ApplicationService) GetApplicationsByOrganization(orgID, projectHandle return nil, constants.ErrProjectNotFound } - apps, err := s.appRepo.GetApplicationsByProjectID(project.ID, orgID) + totalCount, err := s.appRepo.CountApplicationsByProjectID(project.ID, orgID, opts.Search) if err != nil { return nil, err } - totalCount := len(apps) - if offset > totalCount { - offset = totalCount - } - - end := totalCount - effectiveLimit := totalCount - if limit > 0 { - effectiveLimit = limit - end = offset + limit - if end > totalCount { - end = totalCount - } + pagedApps, err := s.appRepo.GetApplicationsByProjectIDPaginated(project.ID, orgID, opts) + if err != nil { + return nil, err } - pagedApps := apps[offset:end] - response := &api.ApplicationListResponse{ Count: len(pagedApps), List: make([]api.Application, 0, len(pagedApps)), Pagination: api.Pagination{ Total: totalCount, - Offset: offset, - Limit: effectiveLimit, + Offset: opts.Offset, + Limit: opts.Limit, }, } @@ -838,25 +826,10 @@ func (s *ApplicationService) buildApplicationAssociationListPaginated(applicatio return nil, err } - if offset < 0 { - offset = 0 - } - + // Associations for one application are a small, bounded set, so the total is + // the full count and the requested window is applied in memory. total := len(associations) - if offset > total { - offset = total - } - - pagedAssociations := associations - effectiveLimit := len(associations) - if limit > 0 { - effectiveLimit = limit - end := offset + limit - if end > total { - end = total - } - pagedAssociations = associations[offset:end] - } + pagedAssociations := paginateSlice(associations, limit, offset) response := &ApplicationAssociationListResponse{ Count: len(pagedAssociations), @@ -864,7 +837,7 @@ func (s *ApplicationService) buildApplicationAssociationListPaginated(applicatio Pagination: api.Pagination{ Total: total, Offset: offset, - Limit: effectiveLimit, + Limit: limit, }, } @@ -887,26 +860,10 @@ func (s *ApplicationService) buildMappedAPIKeyListPaginated(applicationUUID stri } func (s *ApplicationService) buildMappedAPIKeyResponse(keys []*model.ApplicationAPIKey, limit, offset int) (*api.MappedAPIKeyListResponse, error) { - - if offset < 0 { - offset = 0 - } - + // Mapped API keys for one application are a small, bounded set, so the total + // is the full count and the requested window is applied in memory. total := len(keys) - if offset > total { - offset = total - } - - pagedKeys := keys - effectiveLimit := len(keys) - if limit > 0 { - effectiveLimit = limit - end := offset + limit - if end > total { - end = total - } - pagedKeys = keys[offset:end] - } + pagedKeys := paginateSlice(keys, limit, offset) response := &api.MappedAPIKeyListResponse{ Count: len(pagedKeys), @@ -914,7 +871,7 @@ func (s *ApplicationService) buildMappedAPIKeyResponse(keys []*model.Application Pagination: api.Pagination{ Total: total, Offset: offset, - Limit: effectiveLimit, + Limit: limit, }, } diff --git a/platform-api/internal/service/application_test.go b/platform-api/internal/service/application_test.go index 5de933fd02..f657f96d87 100644 --- a/platform-api/internal/service/application_test.go +++ b/platform-api/internal/service/application_test.go @@ -85,20 +85,20 @@ func (m *mockApplicationRepository) GetApplicationsByOrganizationID(orgID string return m.applications, nil } -func (m *mockApplicationRepository) GetApplicationsByProjectIDPaginated(projectID, orgID string, limit, offset int) ([]*model.Application, error) { +func (m *mockApplicationRepository) GetApplicationsByProjectIDPaginated(projectID, orgID string, opts repository.ListOptions) ([]*model.Application, error) { if m.appErr != nil { return nil, m.appErr } - end := offset + limit - if offset > len(m.applications) { + end := opts.Offset + opts.Limit + if opts.Offset > len(m.applications) { return []*model.Application{}, nil } if end > len(m.applications) { end = len(m.applications) } - return m.applications[offset:end], nil + return m.applications[opts.Offset:end], nil } func (m *mockApplicationRepository) GetApplicationsByOrganizationIDPaginated(orgID string, limit, offset int) ([]*model.Application, error) { @@ -117,7 +117,7 @@ func (m *mockApplicationRepository) GetApplicationsByOrganizationIDPaginated(org return m.applications[offset:end], nil } -func (m *mockApplicationRepository) CountApplicationsByProjectID(projectID, orgID string) (int, error) { +func (m *mockApplicationRepository) CountApplicationsByProjectID(projectID, orgID, search string) (int, error) { if m.appErr != nil { return 0, m.appErr } @@ -523,7 +523,7 @@ func TestGetApplicationsByOrganization_AppliesPagination(t *testing.T) { identity: newTestIdentityService(), } - resp, err := svc.GetApplicationsByOrganization(orgID, projectID, 1, 1) + resp, err := svc.GetApplicationsByOrganization(orgID, projectID, repository.ListOptions{Limit: 1, Offset: 1}) if err != nil { t.Fatalf("GetApplicationsByOrganization returned error: %v", err) } diff --git a/platform-api/internal/service/custom_policy_test.go b/platform-api/internal/service/custom_policy_test.go index a9b2780eae..6baa737067 100644 --- a/platform-api/internal/service/custom_policy_test.go +++ b/platform-api/internal/service/custom_policy_test.go @@ -655,7 +655,7 @@ func TestListCustomPolicies(t *testing.T) { } svc := newTestGatewayService(&mockGatewayRepoForPolicy{}, cpRepo) - policies, err := svc.ListCustomPolicies(orgID) + policies, _, err := svc.ListCustomPolicies(orgID, 20, 0) if (err != nil) != tt.wantErr { t.Errorf("ListCustomPolicies() error = %v, wantErr %v", err, tt.wantErr) diff --git a/platform-api/internal/service/gateway.go b/platform-api/internal/service/gateway.go index 8ee28c3e22..64db2fa492 100644 --- a/platform-api/internal/service/gateway.go +++ b/platform-api/internal/service/gateway.go @@ -24,13 +24,13 @@ import ( "encoding/hex" "encoding/json" "fmt" - "log/slog" "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" + "log/slog" "regexp" "strconv" "strings" @@ -483,8 +483,17 @@ func (s *GatewayService) SyncCustomPolicy(gatewayID, orgID, policyName, version } // ListCustomPolicies returns all custom policies synced for the given organization. -func (s *GatewayService) ListCustomPolicies(orgID string) ([]*model.CustomPolicy, error) { - return s.customPolicyRepo.ListCustomPolicyByOrganization(orgID) +// ListCustomPolicies returns a page of custom policies for an organization +// together with the total count across all pages. +func (s *GatewayService) ListCustomPolicies(orgID string, limit, offset int) ([]*model.CustomPolicy, int, error) { + policies, err := s.customPolicyRepo.ListCustomPolicyByOrganization(orgID) + if err != nil { + return nil, 0, err + } + // Custom policies for one organization are a small, bounded set, so the total + // is the full count and the requested window is applied in memory. + total := len(policies) + return paginateSlice(policies, limit, offset), total, nil } // GetCustomPolicyByUUID returns a custom policy by UUID, verifying org ownership and version. @@ -627,17 +636,19 @@ func (s *GatewayService) RegisterGateway(orgID string, id *string, displayName, } // ListGateways retrieves all gateways with constitution-compliant envelope structure -func (s *GatewayService) ListGateways(orgID *string) (*api.GatewayListResponse, error) { - var gateways []*model.Gateway - var err error +func (s *GatewayService) ListGateways(orgID *string, opts repository.ListOptions) (*api.GatewayListResponse, error) { + // Empty orgID scopes to all organizations (internal/admin usage). + scope := "" + if orgID != nil { + scope = *orgID + } - // If orgID provided and non-empty, filter by organization - if orgID != nil && *orgID != "" { - gateways, err = s.gatewayRepo.GetByOrganizationID(*orgID) - } else { - gateways, err = s.gatewayRepo.List() + total, err := s.gatewayRepo.CountGateways(scope, opts.Search) + if err != nil { + return nil, fmt.Errorf("failed to count gateways: %w", err) } + gateways, err := s.gatewayRepo.ListPaginated(scope, opts) if err != nil { return nil, fmt.Errorf("failed to list gateways: %w", err) } @@ -661,9 +672,9 @@ func (s *GatewayService) ListGateways(orgID *string) (*api.GatewayListResponse, Count: len(responses), List: responses, Pagination: api.Pagination{ - Total: len(responses), - Offset: 0, - Limit: len(responses), + Total: total, + Offset: opts.Offset, + Limit: opts.Limit, }, }, nil } @@ -773,8 +784,8 @@ func (s *GatewayService) VerifyToken(plainToken string) (*model.Gateway, error) return gateway, nil } -// ListTokens retrieves all active tokens for a gateway -func (s *GatewayService) ListTokens(gatewayId, orgId string) ([]api.TokenInfoResponse, error) { +// ListTokens retrieves the active tokens for a gateway as a paginated list response. +func (s *GatewayService) ListTokens(gatewayId, orgId string, limit, offset int) (*api.GatewayTokenListResponse, error) { gateway, err := s.gatewayRepo.GetByHandleAndOrgID(gatewayId, orgId) if err != nil { return nil, fmt.Errorf("failed to query gateway: %w", err) @@ -805,7 +816,16 @@ func (s *GatewayService) ListTokens(gatewayId, orgId string) ([]api.TokenInfoRes }) } - return tokens, nil + // A gateway has at most two active tokens, so the total is the full count + // and the requested window is applied in memory. + total := len(tokens) + page := paginateSlice(tokens, limit, offset) + + return &api.GatewayTokenListResponse{ + Count: len(page), + List: page, + Pagination: api.Pagination{Total: total, Offset: offset, Limit: limit}, + }, nil } // RotateToken generates a new token for a gateway (max 2 active tokens) diff --git a/platform-api/internal/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index eb936237e0..7732cbf1d5 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -65,6 +65,7 @@ func NewLLMProviderAPIKeyService( func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( ctx context.Context, providerID, orgID, userID string, + limit, offset int, ) (*api.LLMProviderAPIKeyListResponse, error) { provider, err := s.llmProviderRepo.GetByID(providerID, orgID) @@ -82,16 +83,36 @@ func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( return nil, fmt.Errorf("failed to list API keys: %w", err) } + items, err := ownedAPIKeyItems(keys, userID, s.identity) + if err != nil { + return nil, err + } + + // API keys for one provider (scoped to the caller) are a small, bounded set, + // so the total is the full count and the window is applied in memory. + total := len(items) + page := paginateSlice(items, limit, offset) + + return &api.LLMProviderAPIKeyListResponse{ + List: page, + Count: len(page), + Pagination: api.Pagination{Total: total, Offset: offset, Limit: limit}, + }, nil +} + +// ownedAPIKeyItems keeps only the keys created by userID and maps them to their +// API representation, resolving each creator UUID to its raw identity. +func ownedAPIKeyItems(keys []*model.APIKey, userID string, identity *IdentityService) ([]api.APIKeyItem, error) { items := make([]api.APIKeyItem, 0, len(keys)) for _, k := range keys { if k.CreatedBy != userID { continue } createdBy := utils.StringPtrIfNotEmpty(k.CreatedBy) - if err := s.identity.ResolveIdentityField(&createdBy); err != nil { + if err := identity.ResolveIdentityField(&createdBy); err != nil { return nil, err } - item := api.APIKeyItem{ + items = append(items, api.APIKeyItem{ Id: &k.Name, DisplayName: k.DisplayName, MaskedApiKey: k.MaskedAPIKey, @@ -102,14 +123,9 @@ func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( ExpiresAt: k.ExpiresAt, Issuer: k.Issuer, AllowedTargets: k.AllowedTargets, - } - items = append(items, item) + }) } - - return &api.LLMProviderAPIKeyListResponse{ - Items: items, - Count: len(items), - }, nil + return items, nil } // DeleteLLMProviderAPIKey deletes the API key from the database and broadcasts a revoke event to gateways. diff --git a/platform-api/internal/service/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index c34638dbad..17e9d6663c 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -65,6 +65,7 @@ func NewLLMProxyAPIKeyService( func (s *LLMProxyAPIKeyService) ListLLMProxyAPIKeys( ctx context.Context, proxyID, orgID, userID string, + limit, offset int, ) (*api.LLMProxyAPIKeyListResponse, error) { proxy, err := s.llmProxyRepo.GetByID(proxyID, orgID) @@ -82,33 +83,20 @@ func (s *LLMProxyAPIKeyService) ListLLMProxyAPIKeys( return nil, fmt.Errorf("failed to list API keys: %w", err) } - items := make([]api.APIKeyItem, 0, len(keys)) - for _, k := range keys { - if k.CreatedBy != userID { - continue - } - createdBy := utils.StringPtrIfNotEmpty(k.CreatedBy) - if err := s.identity.ResolveIdentityField(&createdBy); err != nil { - return nil, err - } - item := api.APIKeyItem{ - Id: &k.Name, - DisplayName: k.DisplayName, - MaskedApiKey: k.MaskedAPIKey, - Status: api.APIKeyItemStatus(k.Status), - CreatedAt: k.CreatedAt, - CreatedBy: createdBy, - UpdatedAt: k.UpdatedAt, - ExpiresAt: k.ExpiresAt, - Issuer: k.Issuer, - AllowedTargets: k.AllowedTargets, - } - items = append(items, item) + items, err := ownedAPIKeyItems(keys, userID, s.identity) + if err != nil { + return nil, err } + // API keys for one proxy (scoped to the caller) are a small, bounded set, + // so the total is the full count and the window is applied in memory. + total := len(items) + page := paginateSlice(items, limit, offset) + return &api.LLMProxyAPIKeyListResponse{ - Items: items, - Count: len(items), + List: page, + Count: len(page), + Pagination: api.Pagination{Total: total, Offset: offset, Limit: limit}, }, nil } diff --git a/platform-api/internal/service/pagination.go b/platform-api/internal/service/pagination.go new file mode 100644 index 0000000000..c169b56e97 --- /dev/null +++ b/platform-api/internal/service/pagination.go @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, 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 service + +import "github.com/wso2/api-platform/platform-api/internal/pagination" + +// paginateSlice returns the window [offset, offset+limit) of items, clamped to +// the slice bounds. It is used for small, bounded collections whose endpoints +// declare limit/offset query parameters but return a bare array (no pagination +// envelope), so honoring the parameters at the database is unnecessary. For +// large collections, pagination is pushed into SQL at the repository layer +// instead. +func paginateSlice[T any](items []T, limit, offset int) []T { + return pagination.Window(items, limit, offset) +} diff --git a/platform-api/internal/service/project.go b/platform-api/internal/service/project.go index 0d4ecc5b33..832ef04930 100644 --- a/platform-api/internal/service/project.go +++ b/platform-api/internal/service/project.go @@ -167,25 +167,30 @@ func (s *ProjectService) GetProjectByHandle(handle, orgId string) (*api.Project, return s.modelToAPI(projectModel, orgHandle) } -func (s *ProjectService) GetProjectsByOrganization(organizationID string) ([]api.Project, error) { +func (s *ProjectService) GetProjectsByOrganization(organizationID string, opts repository.ListOptions) ([]api.Project, int, error) { org, err := s.orgRepo.GetOrganizationByUUID(organizationID) if err != nil { - return nil, err + return nil, 0, err } if org == nil { - return nil, apperror.OrganizationNotFound.New() + return nil, 0, apperror.OrganizationNotFound.New() } - projectModels, err := s.projectRepo.GetProjectsByOrganizationID(organizationID) + total, err := s.projectRepo.CountProjects(organizationID, opts.Search) if err != nil { - return nil, err + return nil, 0, err + } + + projectModels, err := s.projectRepo.ListProjects(organizationID, opts) + if err != nil { + return nil, 0, err } projects := make([]api.Project, 0) for _, projectModel := range projectModels { apiProj, err := s.modelToAPI(projectModel, org.Handle) if err != nil { - return nil, err + return nil, 0, err } if apiProj == nil { s.slogger.Warn("Failed to convert project model to API", "organizationId", organizationID) @@ -195,7 +200,7 @@ func (s *ProjectService) GetProjectsByOrganization(organizationID string) ([]api apiProj.UpdatedBy = nil projects = append(projects, *apiProj) } - return projects, nil + return projects, total, nil } func (s *ProjectService) UpdateProject(handle string, req *api.Project, orgId, actor string) (*api.Project, error) { @@ -279,7 +284,7 @@ func (s *ProjectService) DeleteProject(handle, orgId, actor string) error { // applications no longer cascade-delete with the project (the project_uuid foreign key was // removed), so refuse deletion while any application still references this project. The caller // must reassign or delete those applications first. - appCount, err := s.appRepo.CountApplicationsByProjectID(project.ID, orgId) + appCount, err := s.appRepo.CountApplicationsByProjectID(project.ID, orgId, "") if err != nil { return err } diff --git a/platform-api/internal/service/secret_service.go b/platform-api/internal/service/secret_service.go index 0f260f6708..28877d4579 100644 --- a/platform-api/internal/service/secret_service.go +++ b/platform-api/internal/service/secret_service.go @@ -152,7 +152,8 @@ func (s *SecretService) List(orgID string, limit, offset int, updatedAfter *time } return &dto.SecretListResponse{ - List: summaries, + Count: len(summaries), + List: summaries, Pagination: dto.Pagination{ Total: total, Limit: limit, diff --git a/platform-api/internal/service/subscription_plan_service.go b/platform-api/internal/service/subscription_plan_service.go index 23cf4dcc17..a2540f073a 100644 --- a/platform-api/internal/service/subscription_plan_service.go +++ b/platform-api/internal/service/subscription_plan_service.go @@ -153,6 +153,11 @@ func (s *SubscriptionPlanService) ListPlans(orgUUID string, limit, offset int) ( return s.planRepo.ListByOrganization(orgUUID, limit, offset) } +// CountPlans returns the total number of subscription plans in an organization. +func (s *SubscriptionPlanService) CountPlans(orgUUID string) (int, error) { + return s.planRepo.CountByOrganization(orgUUID) +} + // UpdatePlan updates a subscription plan func (s *SubscriptionPlanService) UpdatePlan(handle, orgUUID, actor string, update *model.SubscriptionPlanUpdate) (*model.SubscriptionPlan, error) { existing, err := s.planRepo.GetByHandleAndOrg(handle, orgUUID) diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index 17746648d2..6477083ca4 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -89,23 +89,8 @@ paths: tags: - Organizations parameters: - - name: limit - in: query - required: false - description: Maximum number of organizations to return per page. - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - required: false - description: Zero-based index of the first organization to return. - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Organizations retrieved successfully @@ -235,6 +220,12 @@ paths: - ap:project:manage tags: - Projects + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' responses: '200': description: Projects retrieved successfully @@ -365,6 +356,11 @@ paths: - REST APIs parameters: - $ref: '#/components/parameters/projectId-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' responses: '200': description: REST APIs retrieved successfully @@ -543,6 +539,8 @@ paths: - Gateways parameters: - $ref: '#/components/parameters/apiId' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of gateways associated with the API, including deployment details @@ -809,6 +807,8 @@ paths: - $ref: '#/components/parameters/apiId' - $ref: '#/components/parameters/gatewayId-Q' - $ref: '#/components/parameters/deploymentStatus-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Deployments retrieved successfully @@ -1080,21 +1080,8 @@ paths: schema: type: string example: groupId:openai&version:v2.0 - - name: limit - in: query - description: Maximum number of LLM provider templates to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of LLM provider templates to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: >- @@ -1466,21 +1453,8 @@ paths: tags: - LLM Providers parameters: - - name: limit - in: query - description: Maximum number of LLM providers to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of LLM providers to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of LLM providers @@ -1673,6 +1647,8 @@ paths: type: string - $ref: '#/components/parameters/gatewayId-Q' - $ref: '#/components/parameters/deploymentStatus-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Deployments retrieved successfully @@ -1898,21 +1874,8 @@ paths: description: Unique identifier of the LLM provider schema: type: string - - name: limit - in: query - description: Maximum number of LLM proxies to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of LLM proxies to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of LLM proxies @@ -2000,6 +1963,8 @@ paths: schema: type: string example: wso2-openai-provider + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of API keys retrieved successfully @@ -2108,21 +2073,8 @@ paths: - LLM Proxies parameters: - $ref: '#/components/parameters/projectId-Q' - - name: limit - in: query - description: Maximum number of LLM proxies to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of LLM proxies to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of LLM proxies @@ -2315,6 +2267,8 @@ paths: type: string - $ref: '#/components/parameters/gatewayId-Q' - $ref: '#/components/parameters/deploymentStatus-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Deployments retrieved successfully @@ -2591,6 +2545,8 @@ paths: schema: type: string example: wso2-openai-proxy + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of API keys retrieved successfully @@ -2697,21 +2653,8 @@ paths: - MCP Proxies parameters: - $ref: '#/components/parameters/projectId-Q' - - name: limit - in: query - description: Maximum number of MCP proxies to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of MCP proxies to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of MCP proxies @@ -2902,6 +2845,8 @@ paths: type: string - $ref: '#/components/parameters/gatewayId-Q' - $ref: '#/components/parameters/deploymentStatus-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Deployments retrieved successfully @@ -3199,6 +3144,12 @@ paths: - ap:gateway:manage tags: - Gateways + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' responses: '200': description: Gateways retrieved successfully @@ -3330,15 +3281,15 @@ paths: - Gateway Tokens parameters: - $ref: '#/components/parameters/gatewayId' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of active tokens content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/TokenInfoResponse' + $ref: '#/components/schemas/GatewayTokenListResponse' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -3462,15 +3413,16 @@ paths: - ap:gateway_custom_policy:manage tags: - Gateway Policies + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of custom policies content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/CustomPolicyResponse' + $ref: '#/components/schemas/CustomPolicyListResponse' '401': $ref: '#/components/responses/Unauthorized' '500': @@ -3684,21 +3636,11 @@ paths: - Applications parameters: - $ref: '#/components/parameters/projectId-Q' - - name: limit - in: query - description: Maximum number of applications to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of applications to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + - $ref: '#/components/parameters/sortBy-Q' + - $ref: '#/components/parameters/sortOrder-Q' + - $ref: '#/components/parameters/query-Q' responses: '200': description: Applications retrieved successfully @@ -3826,21 +3768,8 @@ paths: - API Keys parameters: - $ref: '#/components/parameters/appId' - - name: limit - in: query - description: Maximum number of mapped API keys to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of mapped API keys to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Mapped API keys retrieved successfully @@ -3945,21 +3874,8 @@ paths: - Applications parameters: - $ref: '#/components/parameters/appId' - - name: limit - in: query - description: Maximum number of associations to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of associations to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Application associations retrieved successfully @@ -4061,21 +3977,8 @@ paths: parameters: - $ref: '#/components/parameters/appId' - $ref: '#/components/parameters/associationId' - - name: limit - in: query - description: Maximum number of mapped API keys to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of mapped API keys to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: Mapped API keys retrieved successfully @@ -4139,6 +4042,9 @@ paths: - ap:subscription_plan:manage tags: - SubscriptionPlans + parameters: + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of subscription plans @@ -4331,21 +4237,8 @@ paths: schema: type: string enum: [ACTIVE, INACTIVE, REVOKED] - - name: limit - in: query - description: Maximum number of subscriptions to return - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - - name: offset - in: query - description: Number of subscriptions to skip - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of subscriptions @@ -4519,6 +4412,8 @@ paths: style: form explode: false example: LlmProxy,LlmProvider + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' responses: '200': description: List of API keys retrieved successfully @@ -4591,19 +4486,8 @@ paths: tags: - Secrets parameters: - - name: limit - in: query - schema: - type: integer - minimum: 1 - maximum: 100 - default: 25 - - name: offset - in: query - schema: - type: integer - minimum: 0 - default: 0 + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' - name: updatedAfter in: query description: RFC3339 timestamp — return only secrets updated after this time. Used by GW controller for incremental polling. @@ -4962,47 +4846,56 @@ components: UserAPIKeyListResponse: type: object required: - - items + - list - count + - pagination properties: - items: + list: type: array items: $ref: '#/components/schemas/UserAPIKeyItem' description: List of API keys count: type: integer - description: Total number of API keys returned + description: Number of API keys in current response + pagination: + $ref: '#/components/schemas/Pagination' LLMProviderAPIKeyListResponse: type: object required: - - items + - list - count + - pagination properties: - items: + list: type: array items: $ref: '#/components/schemas/APIKeyItem' description: List of API keys count: type: integer - description: Total number of API keys returned + description: Number of API keys in current response + pagination: + $ref: '#/components/schemas/Pagination' LLMProxyAPIKeyListResponse: type: object required: - - items + - list - count + - pagination properties: - items: + list: type: array items: $ref: '#/components/schemas/APIKeyItem' description: List of API keys count: type: integer - description: Total number of API keys returned + description: Number of API keys in current response + pagination: + $ref: '#/components/schemas/Pagination' Organization: type: object @@ -6486,13 +6379,21 @@ components: SubscriptionPlanListResponse: type: object + required: + - list + - count + - pagination properties: - subscriptionPlans: + list: type: array items: $ref: '#/components/schemas/SubscriptionPlan' + description: List of subscription plans in current response count: type: integer + description: Number of subscription plans in current response + pagination: + $ref: '#/components/schemas/Pagination' CreateSubscriptionRequest: type: object @@ -6593,14 +6494,15 @@ components: SubscriptionListResponse: type: object required: - - subscriptions + - list - count - pagination properties: - subscriptions: + list: type: array items: $ref: '#/components/schemas/Subscription' + description: List of subscriptions in current response count: type: integer description: Number of subscriptions in current response @@ -6763,15 +6665,18 @@ components: required: - count - list + - pagination properties: count: type: integer - description: Total number of deployments + description: Number of deployments in current response list: type: array items: $ref: '#/components/schemas/DeploymentResponse' description: List of deployments + pagination: + $ref: '#/components/schemas/Pagination' Upstream: type: object @@ -8449,9 +8354,13 @@ components: SecretListResponse: type: object required: + - count - list - pagination properties: + count: + type: integer + description: Number of secrets in current response list: type: array items: @@ -8459,6 +8368,42 @@ components: pagination: $ref: '#/components/schemas/Pagination' + GatewayTokenListResponse: + type: object + required: + - count + - list + - pagination + properties: + count: + type: integer + description: Number of tokens in current response + list: + type: array + items: + $ref: '#/components/schemas/TokenInfoResponse' + description: List of active tokens + pagination: + $ref: '#/components/schemas/Pagination' + + CustomPolicyListResponse: + type: object + required: + - count + - list + - pagination + properties: + count: + type: integer + description: Number of custom policies in current response + list: + type: array + items: + $ref: '#/components/schemas/CustomPolicyResponse' + description: List of custom policies + pagination: + $ref: '#/components/schemas/Pagination' + responses: Unauthorized: description: Unauthorized. Authentication credentials are missing or invalid. @@ -8687,6 +8632,68 @@ components: - ARCHIVED description: Filter deployments by status (DEPLOYED, UNDEPLOYED, DEPLOYING, UNDEPLOYING, FAILED, or ARCHIVED) + limit-Q: + name: limit + in: query + required: false + description: Maximum number of items to return per page. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: 20 + + offset-Q: + name: offset + in: query + required: false + description: Zero-based index of the first item to return. + schema: + type: integer + minimum: 0 + default: 0 + example: 0 + + sortBy-Q: + name: sortBy + in: query + required: false + description: >- + Field to sort the collection by. An unrecognized value falls back to the + default sort (createdAt). + schema: + type: string + enum: + - name + - createdAt + default: createdAt + example: createdAt + + sortOrder-Q: + name: sortOrder + in: query + required: false + description: Sort direction applied to `sortBy`. + schema: + type: string + enum: + - asc + - desc + default: desc + example: desc + + query-Q: + name: query + in: query + required: false + description: >- + Case-insensitive substring filter matched against the resource id + (handle). + schema: + type: string + example: payment + tags: - name: Health description: Health check endpoints diff --git a/portals/ai-workspace/src/apis/keyManagementApis.ts b/portals/ai-workspace/src/apis/keyManagementApis.ts index 740b993745..e000349973 100644 --- a/portals/ai-workspace/src/apis/keyManagementApis.ts +++ b/portals/ai-workspace/src/apis/keyManagementApis.ts @@ -18,8 +18,10 @@ import { get, del } from '../clients/choreoApiClient'; import { logger } from '../utils/logger'; +import { fetchAllPages } from '../utils/pagination'; import type { + UserAPIKey, UserAPIKeyListResponse, } from '../utils/types'; @@ -66,16 +68,14 @@ export async function listUserAPIKeys( type?: string ): Promise { try { - let url = `/me/api-keys?organizationId=${encodeURIComponent(organizationId)}`; - if (type) { - url += `&type=${encodeURIComponent(type)}`; - } - const response = await get( - url, - undefined, - baseUrl - ); - return response; + return await fetchAllPages(async (limit, offset) => { + let url = `/me/api-keys?organizationId=${encodeURIComponent(organizationId)}`; + if (type) { + url += `&type=${encodeURIComponent(type)}`; + } + url += `&limit=${limit}&offset=${offset}`; + return get(url, undefined, baseUrl); + }); } catch (error) { logger.error('Failed to list user API keys:', error); throw error; diff --git a/portals/ai-workspace/src/apis/llmProviderApis.ts b/portals/ai-workspace/src/apis/llmProviderApis.ts index 336150c708..300e8ed7f9 100644 --- a/portals/ai-workspace/src/apis/llmProviderApis.ts +++ b/portals/ai-workspace/src/apis/llmProviderApis.ts @@ -36,8 +36,10 @@ import type { CreateLLMProviderAPIKeyRequest, CreateLLMProviderAPIKeyResponse, APIKeyListResponse, + UserAPIKey, } from '../utils/types'; import { buildFullProviderRequest } from '../utils/tmpSPRequest'; +import { fetchAllPages } from '../utils/pagination'; const sanitizeRateLimitEntry = (entry: unknown) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { @@ -579,12 +581,14 @@ export async function getLLMProviderAPIKeys( organizationId: string ): Promise { try { - const response = await get( - `/llm-providers/${encodeURIComponent(providerId)}/api-keys`, - undefined, - PLATFORM_API_BASE_URL + return await fetchAllPages((limit, offset) => + get( + `/llm-providers/${encodeURIComponent(providerId)}/api-keys` + + `?limit=${limit}&offset=${offset}`, + undefined, + PLATFORM_API_BASE_URL + ) ); - return response; } catch (error) { logger.error(`Failed to fetch API keys for provider ${providerId}:`, error); throw error; diff --git a/portals/ai-workspace/src/apis/llmProxiesApis.ts b/portals/ai-workspace/src/apis/llmProxiesApis.ts index 45799d40fb..95e53650ca 100644 --- a/portals/ai-workspace/src/apis/llmProxiesApis.ts +++ b/portals/ai-workspace/src/apis/llmProxiesApis.ts @@ -26,7 +26,9 @@ import type { CreateLLMProxyAPIKeyRequest, CreateLLMProxyAPIKeyResponse, APIKeyListResponse, + UserAPIKey, } from '../utils/types'; +import { fetchAllPages } from '../utils/pagination'; // ============================================================================ // LLM Proxy Deployment API Functions @@ -259,12 +261,14 @@ export async function getLLMProxyAPIKeys( organizationId: string ): Promise { try { - const response = await get( - `/llm-proxies/${encodeURIComponent(proxyId)}/api-keys`, - undefined, - PLATFORM_API_BASE_URL + return await fetchAllPages((limit, offset) => + get( + `/llm-proxies/${encodeURIComponent(proxyId)}/api-keys` + + `?limit=${limit}&offset=${offset}`, + undefined, + PLATFORM_API_BASE_URL + ) ); - return response; } catch (error) { logger.error(`Failed to fetch API keys for LLM proxy ${proxyId}:`, error); throw error; diff --git a/portals/ai-workspace/src/contexts/KeyManagementContext.tsx b/portals/ai-workspace/src/contexts/KeyManagementContext.tsx index c4991f0fcd..d49ca43476 100644 --- a/portals/ai-workspace/src/contexts/KeyManagementContext.tsx +++ b/portals/ai-workspace/src/contexts/KeyManagementContext.tsx @@ -39,13 +39,14 @@ import { logger } from '../utils/logger'; const EMPTY_USER_API_KEYS_RESPONSE: UserAPIKeyListResponse = { count: 0, - items: [], + list: [], + pagination: { total: 0, offset: 0, limit: 0 }, }; type KeyManagementContextValue = { /** Convenience accessor for the user API keys list */ userAPIKeys: UserAPIKey[]; - /** Full API response with count and list */ + /** Full API response with count, list, and pagination */ userAPIKeysResponse: UserAPIKeyListResponse; isLoading: boolean; error: Error | null; @@ -150,7 +151,7 @@ export function KeyManagementProvider({ const value = useMemo( () => ({ - userAPIKeys: userAPIKeysResponse.items, + userAPIKeys: userAPIKeysResponse.list, userAPIKeysResponse, isLoading, error, diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/APIKeyTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/APIKeyTab.tsx index feeb0bbd2e..1e947a79eb 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/APIKeyTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/APIKeyTab.tsx @@ -202,7 +202,7 @@ export default function APIKeyTab({ applicationId }: APIKeyTabProps) { currentOrganization?.uuid ?? '', apimBaseUrl ); - setAvailableKeys(response.items ?? []); + setAvailableKeys(response.list ?? []); } catch (error) { showSnackbar( getErrorDescription(error, 'Failed to load available API keys.'), diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/AssociationsTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/AssociationsTab.tsx index 46d9fa2c7a..6ccc478ce7 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/AssociationsTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/applications/OverviewTabs/AssociationsTab.tsx @@ -101,7 +101,7 @@ type LoadEntityKeysArgs = { entityId: string, orgUuid: string ) => Promise<{ - items?: UserAPIKey[]; + list?: UserAPIKey[]; }>; preselectLatest?: boolean; unavailableKeyNames?: Set; @@ -352,7 +352,7 @@ async function loadEntityKeys({ try { const response = await fetchKeys(entityId, orgUuid); - const activeKeys = (response.items ?? []).filter( + const activeKeys = (response.list ?? []).filter( (key) => key.status === 'active' ); const latestKey = getLatestSelectableKey(activeKeys, unavailableKeyNames); diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx index 49e7b8d2fe..c7b8049cab 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/proxies/LLMProxyOverviewTab.tsx @@ -336,7 +336,7 @@ export default function LLMProxyOverviewTab() { try { const response = await getProxyAPIKeys(); if (!isMounted) return; - setApiKeys(response.items || []); + setApiKeys(response.list || []); fetchedApiKeysProxyIdRef.current = proxyId; } catch (fetchError) { if (!isMounted) return; @@ -407,7 +407,7 @@ export default function LLMProxyOverviewTab() { setIsApiKeyModalOpen(true); try { const refreshedApiKeys = await getProxyAPIKeys(); - setApiKeys(refreshedApiKeys.items || []); + setApiKeys(refreshedApiKeys.list || []); } catch (fetchError) { logger.error( `Failed to refresh API keys for proxy ${proxy.id}:`, diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/quickStart/lllmStepBanner/LLLMStepBanner.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/quickStart/lllmStepBanner/LLLMStepBanner.tsx index 785a5c31b9..15be9f8b68 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/quickStart/lllmStepBanner/LLLMStepBanner.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/quickStart/lllmStepBanner/LLLMStepBanner.tsx @@ -124,8 +124,7 @@ export default function LLLMStepBanner({ : false, hasConsumptions: apiKeysResult.status === 'fulfilled' - ? (apiKeysResult.value.count ?? apiKeysResult.value.items?.length ?? 0) > - 0 + ? (apiKeysResult.value.list?.length ?? 0) > 0 : false, }); }; diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx index b5abc19362..1977f253e5 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderDeploymentsCard.tsx @@ -169,7 +169,7 @@ export default function ServiceProviderDeploymentsCard({ try { const response = await getProviderAPIKeys(); if (!isMounted) return; - setApiKeys(response.items || []); + setApiKeys(response.list || []); fetchedApiKeysProviderIdRef.current = providerId; } catch (fetchError) { if (!isMounted) return; @@ -237,7 +237,7 @@ export default function ServiceProviderDeploymentsCard({ try { const refreshedApiKeys = await getProviderAPIKeys(); - setApiKeys(refreshedApiKeys.items || []); + setApiKeys(refreshedApiKeys.list || []); } catch (fetchError) { logger.error( `Failed to refresh API keys for provider ${provider.id}:`, diff --git a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx index 9b1d673afb..fa39d56aae 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellPages/serviceProvider/ServiceProviderOverviewTab.tsx @@ -449,7 +449,7 @@ export default function ServiceProviderOverviewTab({ try { const response = await getProviderAPIKeys(); if (!isMounted) return; - setApiKeys(response.items || []); + setApiKeys(response.list || []); fetchedApiKeysProviderIdRef.current = providerId; } catch (fetchError) { if (!isMounted) return; @@ -524,7 +524,7 @@ export default function ServiceProviderOverviewTab({ setIsApiKeyModalOpen(true); try { const refreshedApiKeys = await getProviderAPIKeys(); - setApiKeys(refreshedApiKeys.items || []); + setApiKeys(refreshedApiKeys.list || []); } catch (fetchError) { logger.error( `Failed to refresh API keys for provider ${provider.id}:`, diff --git a/portals/ai-workspace/src/utils/pagination.ts b/portals/ai-workspace/src/utils/pagination.ts new file mode 100644 index 0000000000..ddf572c134 --- /dev/null +++ b/portals/ai-workspace/src/utils/pagination.ts @@ -0,0 +1,71 @@ +/* + * 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. + */ + +import type { ApiListResponse } from './types'; + +/** + * Largest `limit` the platform API accepts on a collection GET. Requests above + * this are clamped server-side rather than rejected, so asking for more only + * wastes the round trip. + */ +export const MAX_PAGE_LIMIT = 100; + +/** + * Upper bound on pages walked by {@link fetchAllPages}, so a server that keeps + * reporting a `total` it never delivers cannot spin this loop forever. + */ +const MAX_PAGES = 100; + +/** + * Collect every page of a paginated collection endpoint into a single response. + * + * Collection GETs default to `limit=20` when the caller omits it, so a plain + * unpaged call silently truncates. Use this for lists the UI renders in full + * (with no pager of its own) and that are known to be small and bounded. + * + * The returned `pagination.total` is the server's total; `count` and `limit` + * describe the merged result. + */ +export async function fetchAllPages( + fetchPage: (limit: number, offset: number) => Promise> +): Promise> { + const all: T[] = []; + let offset = 0; + let total = 0; + + for (let page = 0; page < MAX_PAGES; page++) { + const response = await fetchPage(MAX_PAGE_LIMIT, offset); + const items = response?.list ?? []; + total = response?.pagination?.total ?? items.length; + + all.push(...items); + + // A short page means the server has nothing further to give, regardless of + // what `total` claims — this is the loop's real termination condition. + if (items.length < MAX_PAGE_LIMIT || all.length >= total) { + break; + } + offset += items.length; + } + + return { + count: all.length, + list: all, + pagination: { total, offset: 0, limit: all.length }, + }; +} diff --git a/portals/ai-workspace/src/utils/types.ts b/portals/ai-workspace/src/utils/types.ts index cdbb06276a..8a9d0e62ca 100644 --- a/portals/ai-workspace/src/utils/types.ts +++ b/portals/ai-workspace/src/utils/types.ts @@ -727,10 +727,7 @@ export interface CreateLLMProxyAPIKeyResponse { /** * API Key list response for LLM providers */ -export interface APIKeyListResponse { - count: number; - items: UserAPIKey[]; -} +export type APIKeyListResponse = ApiListResponse; // ============================================================================ // MCP Server Types @@ -1061,7 +1058,4 @@ export interface UserAPIKey { /** * User API Key list response */ -export interface UserAPIKeyListResponse { - count: number; - items: UserAPIKey[]; -} +export type UserAPIKeyListResponse = ApiListResponse; diff --git a/tests/integration-e2e/steps_devportal_lifecycle_test.go b/tests/integration-e2e/steps_devportal_lifecycle_test.go index 50d95f2071..9237e87e55 100644 --- a/tests/integration-e2e/steps_devportal_lifecycle_test.go +++ b/tests/integration-e2e/steps_devportal_lifecycle_test.go @@ -160,10 +160,10 @@ func (w *world) verifySubPlan() error { st, body, err := apiCall(http.MethodGet, "/subscriptions?apiId="+w.apiID, suite.token, nil) if err == nil && st == http.StatusOK { var env struct { - Subscriptions []map[string]any `json:"subscriptions"` + List []map[string]any `json:"list"` } - if json.Unmarshal(body, &env) == nil && len(env.Subscriptions) > 0 { - last = stringField(env.Subscriptions[0], "subscriptionPlanName") + if json.Unmarshal(body, &env) == nil && len(env.List) > 0 { + last = stringField(env.List[0], "subscriptionPlanName") if last == w.plan2ID { return nil }