From f51a8206276a64a29c37dfbd7bb742a6a6b5a20b Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 9 Jul 2026 10:55:32 +0530 Subject: [PATCH 1/3] Implement pagination support across various API handlers and repositories This commit introduces a new pagination mechanism by adding a `parsePagination` function to standardize the handling of `limit` and `offset` query parameters. The pagination logic has been integrated into multiple API handlers, allowing for more efficient data retrieval and response structuring. Additionally, corresponding changes have been made in the repository layer to support paginated queries for APIs, applications, gateways, and subscription plans, enhancing the overall performance and usability of the API platform. --- platform-api/api/generated.go | 469 ++++++++++++++---- platform-api/internal/dto/secret.go | 1 + platform-api/internal/handler/api.go | 14 +- .../internal/handler/api_deployment.go | 3 + platform-api/internal/handler/apikey_user.go | 4 +- platform-api/internal/handler/application.go | 86 +--- platform-api/internal/handler/gateway.go | 22 +- platform-api/internal/handler/llm.go | 89 +--- platform-api/internal/handler/llm_apikey.go | 4 +- .../internal/handler/llm_deployment.go | 6 + .../internal/handler/llm_proxy_apikey.go | 4 +- platform-api/internal/handler/mcp.go | 25 +- .../internal/handler/mcp_deployment.go | 3 + platform-api/internal/handler/organization.go | 19 +- platform-api/internal/handler/pagination.go | 122 +++++ platform-api/internal/handler/project.go | 10 +- platform-api/internal/handler/secret.go | 17 +- .../handler/secret_integration_test.go | 10 +- .../internal/handler/subscription_handler.go | 29 +- .../handler/subscription_plan_handler.go | 39 +- platform-api/internal/repository/api.go | 65 +++ .../internal/repository/application.go | 46 +- platform-api/internal/repository/gateway.go | 72 +++ .../internal/repository/interfaces.go | 12 +- .../internal/repository/pagination.go | 79 +++ .../internal/repository/pagination_test.go | 94 ++++ platform-api/internal/repository/project.go | 34 +- .../subscription_plan_repository.go | 11 + platform-api/internal/service/api.go | 58 ++- platform-api/internal/service/apikey_user.go | 11 +- platform-api/internal/service/application.go | 85 +--- .../internal/service/application_test.go | 12 +- .../internal/service/custom_policy_test.go | 2 +- platform-api/internal/service/gateway.go | 54 +- platform-api/internal/service/llm_apikey.go | 11 +- .../internal/service/llm_proxy_apikey.go | 11 +- platform-api/internal/service/pagination.go | 39 ++ platform-api/internal/service/project.go | 21 +- .../internal/service/secret_service.go | 3 +- .../service/subscription_plan_service.go | 5 + platform-api/resources/openapi.yaml | 405 +++++++-------- 41 files changed, 1386 insertions(+), 720 deletions(-) create mode 100644 platform-api/internal/handler/pagination.go create mode 100644 platform-api/internal/repository/pagination.go create mode 100644 platform-api/internal/repository/pagination_test.go create mode 100644 platform-api/internal/service/pagination.go 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 2dfe162fb0..e7cd9e81c7 100644 --- a/platform-api/internal/handler/organization.go +++ b/platform-api/internal/handler/organization.go @@ -23,8 +23,6 @@ import ( "fmt" "log/slog" "net/http" - "strconv" - "strings" "github.com/wso2/api-platform/platform-api/api" "github.com/wso2/api-platform/platform-api/internal/apperror" @@ -153,22 +151,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..c9d150c254 --- /dev/null +++ b/platform-api/internal/handler/pagination.go @@ -0,0 +1,122 @@ +/* + * 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/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. Missing, malformed, or +// out-of-range values fall back to the documented defaults/bounds rather than +// erroring, 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 = parsed + } + } + if limit < minPageLimit { + limit = defaultPageLimit + } + if limit > maxPageLimit { + limit = 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 returns the [offset, offset+limit) slice of items, clamped to the +// bounds. Used to window a fully-materialized, bounded collection in the handler +// before responding. +func pageWindow[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] +} + +// 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/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..ea6aab9115 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) @@ -106,9 +107,15 @@ func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( items = append(items, item) } + // 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{ - 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/llm_proxy_apikey.go b/platform-api/internal/service/llm_proxy_apikey.go index c34638dbad..654e9e4aca 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) @@ -106,9 +107,15 @@ func (s *LLMProxyAPIKeyService) ListLLMProxyAPIKeys( items = append(items, item) } + // 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..167820ff5f --- /dev/null +++ b/platform-api/internal/service/pagination.go @@ -0,0 +1,39 @@ +/* + * 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 + +// 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. A non-positive limit yields an empty window; a negative offset is +// treated as 0. +func paginateSlice[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/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 416d7d9ef2..55a9477faf 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. @@ -8685,6 +8630,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 From 3f38536aa4ad77ec8e5109476d90e204d674b81f Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 9 Jul 2026 12:00:21 +0530 Subject: [PATCH 2/3] Refactor API key retrieval to support pagination This commit updates the API key retrieval functions across multiple modules to utilize a new pagination mechanism. The `fetchAllPages` utility is introduced to streamline the fetching of paginated data, ensuring that all pages of API keys are collected efficiently. Additionally, the response structure has been modified to use `list` instead of `items`, and pagination details are now included in the responses, enhancing the overall data handling and user experience. --- .../src/apis/keyManagementApis.ts | 20 +++--- .../ai-workspace/src/apis/llmProviderApis.ts | 14 ++-- .../ai-workspace/src/apis/llmProxiesApis.ts | 14 ++-- .../src/contexts/KeyManagementContext.tsx | 7 +- .../applications/OverviewTabs/APIKeyTab.tsx | 2 +- .../OverviewTabs/AssociationsTab.tsx | 4 +- .../proxies/LLMProxyOverviewTab.tsx | 4 +- .../lllmStepBanner/LLLMStepBanner.tsx | 3 +- .../ServiceProviderDeploymentsCard.tsx | 4 +- .../ServiceProviderOverviewTab.tsx | 4 +- portals/ai-workspace/src/utils/pagination.ts | 71 +++++++++++++++++++ portals/ai-workspace/src/utils/types.ts | 10 +-- 12 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 portals/ai-workspace/src/utils/pagination.ts 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 8ad0ddf300..8f6edd545e 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 @@ -206,7 +206,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 3d737fe8f2..c17203df58 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 @@ -100,7 +100,7 @@ type LoadEntityKeysArgs = { entityId: string, orgUuid: string ) => Promise<{ - items?: UserAPIKey[]; + list?: UserAPIKey[]; }>; preselectLatest?: boolean; unavailableKeyNames?: Set; @@ -356,7 +356,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; From b8dc8d0d1717e3b866569f51882a1b5c2e51b6d0 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 9 Jul 2026 13:46:02 +0530 Subject: [PATCH 3/3] Refactor pagination handling and introduce dedicated pagination package This commit refines the pagination logic by introducing a new `pagination` package that centralizes the windowing functionality. The `parsePagination` function has been updated to clamp out-of-range values, ensuring robust handling of `limit` and `offset` parameters. Additionally, various service methods have been modified to utilize this new pagination mechanism, enhancing code clarity and maintainability. The response structures in API key retrieval functions have also been updated to align with the new pagination approach. --- platform-api/internal/handler/pagination.go | 31 ++++----------- .../internal/integration/lifecycle_test.go | 2 +- .../internal/pagination/pagination.go | 38 ++++++++++++++++++ platform-api/internal/service/llm_apikey.go | 39 ++++++++++++------- .../internal/service/llm_proxy_apikey.go | 25 ++---------- platform-api/internal/service/pagination.go | 17 ++------ .../steps_devportal_lifecycle_test.go | 6 +-- 7 files changed, 81 insertions(+), 77 deletions(-) create mode 100644 platform-api/internal/pagination/pagination.go diff --git a/platform-api/internal/handler/pagination.go b/platform-api/internal/handler/pagination.go index c9d150c254..abcb3eea3f 100644 --- a/platform-api/internal/handler/pagination.go +++ b/platform-api/internal/handler/pagination.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -37,22 +38,17 @@ const ( ) // parsePagination extracts and normalizes the `limit` and `offset` query -// parameters according to the spec contract. Missing, malformed, or -// out-of-range values fall back to the documented defaults/bounds rather than -// erroring, so a client can never push the API outside its safe window. +// 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 = parsed + limit = min(max(parsed, minPageLimit), maxPageLimit) } } - if limit < minPageLimit { - limit = defaultPageLimit - } - if limit > maxPageLimit { - limit = maxPageLimit - } offset = defaultPageOffset if v := strings.TrimSpace(r.URL.Query().Get("offset")); v != "" { @@ -90,21 +86,10 @@ func parseListOptions(r *http.Request) repository.ListOptions { } } -// pageWindow returns the [offset, offset+limit) slice of items, clamped to the -// bounds. Used to window a fully-materialized, bounded collection in the handler +// pageWindow windows a fully-materialized, bounded collection in the handler // before responding. func pageWindow[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] + return pagination.Window(items, limit, offset) } // paginateDeploymentList windows a fully-materialized deployment list response 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/service/llm_apikey.go b/platform-api/internal/service/llm_apikey.go index ea6aab9115..7732cbf1d5 100644 --- a/platform-api/internal/service/llm_apikey.go +++ b/platform-api/internal/service/llm_apikey.go @@ -83,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, @@ -103,20 +123,9 @@ func (s *LLMProviderAPIKeyService) ListLLMProviderAPIKeys( ExpiresAt: k.ExpiresAt, Issuer: k.Issuer, AllowedTargets: k.AllowedTargets, - } - items = append(items, item) + }) } - - // 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 + 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 654e9e4aca..17e9d6663c 100644 --- a/platform-api/internal/service/llm_proxy_apikey.go +++ b/platform-api/internal/service/llm_proxy_apikey.go @@ -83,28 +83,9 @@ 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, diff --git a/platform-api/internal/service/pagination.go b/platform-api/internal/service/pagination.go index 167820ff5f..c169b56e97 100644 --- a/platform-api/internal/service/pagination.go +++ b/platform-api/internal/service/pagination.go @@ -17,23 +17,14 @@ 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. A non-positive limit yields an empty window; a negative offset is -// treated as 0. +// instead. func paginateSlice[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] + return pagination.Window(items, limit, offset) } 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 }