diff --git a/docs/rest-apis/platform-api/authentication.md b/docs/rest-apis/platform-api/authentication.md index 0f529f38d7..09d53eec9a 100644 --- a/docs/rest-apis/platform-api/authentication.md +++ b/docs/rest-apis/platform-api/authentication.md @@ -261,6 +261,8 @@ login endpoint described under [Obtaining a token](#obtaining-a-token) |ap:application:manage|Full access to applications| |ap:application:read|Read applications| |ap:application:update|Update an application| +|ap:docs:manage|Full access to API documents| +|ap:docs:read|Read API documents| |ap:gateway:create|Create a gateway| |ap:gateway:delete|Delete a gateway| |ap:gateway:manage|Full access to gateways| @@ -365,3 +367,5 @@ login endpoint described under [Obtaining a token](#obtaining-a-token) |ap:subscription_plan:manage|Full access to subscription plans| |ap:subscription_plan:read|Read subscription plans| |ap:subscription_plan:update|Update a subscription plan| +|ap:thumbnail:manage|Full access to API thumbnails| +|ap:thumbnail:read|Read API thumbnails| diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index 21865cf293..4004cd9f3b 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -175,6 +175,8 @@ config: # Ownership override — every user's API keys, not just the caller's. # Never implied by ap:api_key:read, so it is listed explicitly. - ap:api_key:all:manage + - ap:docs:manage + - ap:thumbnail:manage # Claim-name mappings shared by all modes. claimMappings: organization: organization diff --git a/platform-api/api/generated.go b/platform-api/api/generated.go index f67e7eccf7..eeac0491f3 100644 --- a/platform-api/api/generated.go +++ b/platform-api/api/generated.go @@ -2013,6 +2013,71 @@ type A2ATransport struct { // A2ATransportProtocolBinding A2A protocol binding served on this transport. type A2ATransportProtocolBinding string +// APIDocumentListResponse defines model for APIDocumentListResponse. +type APIDocumentListResponse struct { + // Count Number of items in the current page. + Count int `json:"count" yaml:"count"` + List []APIDocumentMetadata `json:"list" yaml:"list"` + Pagination Pagination `json:"pagination" yaml:"pagination"` +} + +// APIDocumentMetadata Metadata-only view of a document attached to an artifact. +type APIDocumentMetadata struct { + // ContentType Stored MIME type, sniffed from the uploaded bytes rather than trusted from the uploader. + ContentType *string `json:"contentType,omitempty" yaml:"contentType,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty" yaml:"createdAt,omitempty"` + + // CreatedBy User who created the document. + CreatedBy *string `json:"createdBy,omitempty" yaml:"createdBy,omitempty"` + DisplayName string `json:"displayName" yaml:"displayName"` + + // FileName Original file name supplied when a `file` was uploaded. + FileName *string `json:"fileName,omitempty" yaml:"fileName,omitempty"` + + // Id URL-safe handle used in the `{docId}` path segment. + Id string `json:"id" yaml:"id"` + + // Type Document type as stored. Fixed types (HOW_TO, SAMPLE_SDK, SUPPORT_FORUM, PUBLIC_FORUM) are returned as-is; custom OTHER types are returned as the bare custom name (e.g. FAQ). + Type string `json:"type" yaml:"type"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" yaml:"updatedAt,omitempty"` + + // UpdatedBy User who updated the document. + UpdatedBy *string `json:"updatedBy,omitempty" yaml:"updatedBy,omitempty"` +} + +// APIDocumentRequest Multipart form for document create (`POST`) and update (`PUT`). +// +// On **create**: `type` and `displayName` are required; `inlineContent` +// must carry the body. `id` is optional — the server generates one from +// `displayName` when omitted, and `fileName` defaults to `{handle}.md`. +// +// On **update**: every field is optional; omitted fields leave the stored +// value unchanged. Omitting `inlineContent` means a metadata-only update +// — the stored bytes are not touched. If `id` is supplied it must match +// the `{docId}` path parameter, otherwise the request is rejected with 400. +type APIDocumentRequest struct { + DisplayName string `json:"displayName" yaml:"displayName"` + + // FileName File name to associate with the content. Defaults to `{handle}.md`. + FileName *string `json:"fileName,omitempty" yaml:"fileName,omitempty"` + + // Id URL-safe document handle. On create: optional, server-generated from + // `displayName` when omitted; must be unique per artifact (409 on + // conflict). On update: if provided, must match the `{docId}` path parameter. + Id *string `json:"id,omitempty" yaml:"id,omitempty"` + + // InlineContent Inline UTF-8 Markdown content. + InlineContent *string `json:"inlineContent,omitempty" yaml:"inlineContent,omitempty"` + + // OtherTypeName Free-form qualifier used when `type` is `Other`. Stored and returned + // exactly as typed (no case conversion). Ignored for all other types. + OtherTypeName *string `json:"otherTypeName,omitempty" yaml:"otherTypeName,omitempty"` + + // Type Document type. Well-known values: `HowTo`, `Samples`, `SupportForum`, + // `PublicForum`, `Other`. Custom types are accepted and stored as-is. + Type string `json:"type" yaml:"type"` +} + // APIKeyItem defines model for APIKeyItem. type APIKeyItem struct { // AllowedTargets Comma-separated list of allowed gateways; 'ALL' means unrestricted @@ -2081,6 +2146,15 @@ type APIKeySecurity struct { // APIKeySecurityIn Location of the API key (header or query) type APIKeySecurityIn string +// APIThumbnailRequest Multipart form for `PUT /apis/{apiType}/{apiId}/thumbnail`. The server +// sniffs the uploaded bytes and accepts only `image/jpeg` or `image/png` +// — the declared `Content-Type` and filename extension are ignored for +// the type decision. +type APIThumbnailRequest struct { + // File JPEG or PNG image bytes. Max size is deployment-configured. + File openapi_types.File `json:"file" yaml:"file"` +} + // AddApplicationAPIKeysRequest defines model for AddApplicationAPIKeysRequest. type AddApplicationAPIKeysRequest struct { // ApiKeys List of API key selectors to add to the application mappings @@ -4984,6 +5058,12 @@ type DeploymentId = openapi_types.UUID // DeploymentStatusQ defines model for deploymentStatus-Q. type DeploymentStatusQ string +// DocId defines model for docId. +type DocId = string + +// DocTypeQ defines model for docType-Q. +type DocTypeQ = string + // EntityIDQ defines model for entityID-Q. type EntityIDQ = string @@ -5231,6 +5311,20 @@ type ListApiPublicationsParamsSortBy string // ListApiPublicationsParamsSortOrder defines parameters for ListApiPublications. type ListApiPublicationsParamsSortOrder string +// ListAPIDocumentsParams defines parameters for ListAPIDocuments. +type ListAPIDocumentsParams struct { + // Type Optional filter restricting the list to documents of a single type. + // An unrecognised value yields an empty page rather than an error, and + // the reserved `DEFINITION` type is never returned via this endpoint. + Type *DocTypeQ `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"` +} + // ListApplicationsParams defines parameters for ListApplications. type ListApplicationsParams struct { // ProjectId **Project ID** consisting of the **handle** (unique slug identifier) of the Project whose resources should be returned. @@ -5768,6 +5862,15 @@ type SaveApiPublicationDraftDefinitionJSONRequestBody = SaveApiPublicationDraftD // SaveApiPublicationDraftThumbnailMultipartRequestBody defines body for SaveApiPublicationDraftThumbnail for multipart/form-data ContentType. type SaveApiPublicationDraftThumbnailMultipartRequestBody SaveApiPublicationDraftThumbnailMultipartBody +// CreateAPIDocumentMultipartRequestBody defines body for CreateAPIDocument for multipart/form-data ContentType. +type CreateAPIDocumentMultipartRequestBody = APIDocumentRequest + +// UpdateAPIDocumentMultipartRequestBody defines body for UpdateAPIDocument for multipart/form-data ContentType. +type UpdateAPIDocumentMultipartRequestBody = APIDocumentRequest + +// UpsertAPIThumbnailMultipartRequestBody defines body for UpsertAPIThumbnail for multipart/form-data ContentType. +type UpsertAPIThumbnailMultipartRequestBody = APIThumbnailRequest + // CreateApplicationJSONRequestBody defines body for CreateApplication for application/json ContentType. type CreateApplicationJSONRequestBody = CreateApplicationRequest diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 29c1a7c8c3..e38dd1c0df 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -111,6 +111,8 @@ type Server struct { // upload. Kept separate from PublicationContentMaxBytes — a thumbnail is a small // icon, not a spec document, so it gets its own, tighter default (2 MiB) when <= 0. PublicationThumbnailMaxBytes int64 `koanf:"publication_thumbnail_max_bytes"` + // ThumbnailMaxFetchBytes bounds a single-artifact thumbnail upload. + ThumbnailMaxFetchBytes int64 `koanf:"thumbnail_max_fetch_bytes"` // AgentCardMaxFetchBytes bounds the body read from an upstream agent's Agent Card // endpoint (internal/utils/agent_card.go). <= 0 falls back to the fetcher's built-in // 1 MiB default, which is the contract's per-card ceiling — mirroring diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index 0f3d37dd31..0e3eb959f2 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -292,3 +292,9 @@ var ( APIPublicationDraftChanged = def(CodeAPIPublicationDraftChanged, http.StatusConflict, "The draft changed while publishing. Review it and publish again.") ) + +// API document entries. +var ( + APIDocumentNameExists = def(CodeAPIDocumentNameExists, http.StatusConflict, + "A document with this name already exists for this API.") +) diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index 2329c9da8e..88dfadc26a 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -193,6 +193,11 @@ const ( CodeArtifactDeployed = "ARTIFACT_DEPLOYED" ) +// API document domain codes. +const ( + CodeAPIDocumentNameExists = "API_DOCUMENT_NAME_EXISTS" +) + // Custom policy domain codes. const ( CodeCustomPolicyNotFound = "CUSTOM_POLICY_NOT_FOUND" diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 702d12b872..62dd33fc79 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -17,7 +17,10 @@ package constants -import "regexp" +import ( + "regexp" + "strings" +) // SecretPlaceholderRe matches {{ secret "handle" }} (and the escaped-quote variant // {{ secret \"handle\" }}) in artifact config blobs. A single definition here ensures @@ -183,6 +186,17 @@ const ( // keep it out of self-service/developer roles. const ScopeAPIKeyAllManage = "ap:api_key:all:manage" +// ScopeDocsRead and ScopeDocsManage govern the /apis/{apiType}/{apiId}/docs endpoints. +const ( + ScopeDocsRead = "ap:docs:read" + ScopeDocsManage = "ap:docs:manage" +) + +const ( + ScopeThumbnailRead = "ap:thumbnail:read" + ScopeThumbnailManage = "ap:thumbnail:manage" +) + // Custom Policy ManagedBy constants const ( PolicyManagedByOrganization = "organization" @@ -299,6 +313,9 @@ var ValidThrottleLimitUnits = map[string]bool{ // upload or fetch when OpenAPISpecMaxFetchBytes is not set in config. const DefaultOpenAPISpecMaxBytes int64 = 5 << 20 // 5 MiB +// DefaultThumbnailMaxBytes bounds a single thumbnail upload. +const DefaultThumbnailMaxBytes int64 = 1 << 20 // 1 MiB + // DefaultOpenAPISpecFileName is the filename persisted for a spec that was // fetched by URL but whose URL has no usable last path segment to name the // file after. @@ -313,6 +330,57 @@ const ( DocumentDisplayNameDefinition = "OpenAPI Definition" ) +const ( + DocumentTypeThumbnail = "THUMBNAIL" + DocumentHandleThumbnail = "api-thumbnail" + DocumentDisplayNameThumbnail = "API Thumbnail" +) + +const ( + DocumentTypeHowTo = "HowTo" + DocumentTypeSamples = "Samples" + DocumentTypeSupportForum = "SupportForum" + DocumentTypePublicForum = "PublicForum" + DocumentTypeOther = "Other" + DocumentTypePrefix = "DOC_" +) + +var ValidAPIDocumentUserTypes = map[string]bool{ + DocumentTypeHowTo: true, + DocumentTypeSamples: true, + DocumentTypeSupportForum: true, + DocumentTypePublicForum: true, + DocumentTypeOther: true, +} + +// Fixed types (HowTo, Samples, …) are stored with the DOC_ prefix to be compatible with the api-portal. +var FixedAPIDocumentStoredTypes = []string{ + DocumentTypePrefix + DocumentTypeHowTo, + DocumentTypePrefix + DocumentTypeSamples, + DocumentTypePrefix + DocumentTypeSupportForum, + DocumentTypePrefix + DocumentTypePublicForum, +} + +var ReservedAPIDocumentTypes = []string{ + DocumentTypeDefinition, + DocumentTypeThumbnail, +} + +var ReservedAPIDocumentHandles = map[string]bool{ + DocumentHandleDefinition: true, + DocumentHandleThumbnail: true, +} + +var ForbiddenOtherTypeNames = map[string]bool{ + strings.ToLower(DocumentTypeDefinition): true, + strings.ToLower(DocumentTypeThumbnail): true, + strings.ToLower(DocumentTypeHowTo): true, + strings.ToLower(DocumentTypeSamples): true, + strings.ToLower(DocumentTypeSupportForum): true, + strings.ToLower(DocumentTypePublicForum): true, + strings.ToLower(DocumentTypeOther): true, +} + // Metadata key constants for deployment metadata const ( // MetadataKeyEndpointUrl is the metadata key for the per-deployment endpoint URL override. diff --git a/platform-api/internal/dto/api_document.go b/platform-api/internal/dto/api_document.go index bb3c6ef281..24788bc09d 100644 --- a/platform-api/internal/dto/api_document.go +++ b/platform-api/internal/dto/api_document.go @@ -17,28 +17,22 @@ package dto -// CreateAPIDocumentRequest carries the raw spec and metadata when persisting a new spec -// document for an API. The service fills in document type, handle, display name, and content type. +// CreateAPIDocumentRequest carries the raw spec and metadata when creating or upserting a +// document for an API. The service fills in the content type. type CreateAPIDocumentRequest struct { - Type string - Handle string - DisplayName string - FileName string - Content []byte + Type string + Handle string + DisplayName string + FileName string + Content []byte + OtherTypeName string // only meaningful when Type == "Other"; stored as-is in the type column } -// PutAPIDocumentRequest carries the raw spec and metadata when replacing an existing -// spec document for an API. The service fills in document type, handle, display name, and content type. -type PutAPIDocumentRequest struct { - Type string - Handle string - DisplayName string - FileName string - Content []byte -} - -// APIDocumentContent is returned by GetDocument — the raw spec bytes ready to serve. -type APIDocumentContent struct { - Content []byte - ContentType string +type UpdateAPIDocumentRequest struct { + Type *string // nil = leave unchanged; pointer to "" is rejected + OtherTypeName string // only meaningful when Type == "Other" + DisplayName *string + FileName *string + Content []byte + ContentType *string } diff --git a/platform-api/internal/handler/api.go b/platform-api/internal/handler/api.go index f56bdc483f..c4f75ca56a 100644 --- a/platform-api/internal/handler/api.go +++ b/platform-api/internal/handler/api.go @@ -431,13 +431,15 @@ func (h *APIHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) erro return serviceError(err, "failed to resolve API "+restApiId+" in org "+orgId) } - // Retrieve document - doc, err := h.apiDocumentService.GetDocument(artifactUUID, orgId) + // Retrieve document — strict match on handle AND type so a user doc that + // somehow registered at the reserved handle can't be returned here. + _, contentBytes, err := h.apiDocumentService.GetDocumentWithContent(artifactUUID, constants.DocumentHandleDefinition, orgId, + constants.DocumentTypeDefinition) if err != nil { return serviceError(err, "failed to fetch openapi spec for API "+restApiId) } - content := string(doc.Content) + content := string(contentBytes) httputil.WriteJSON(w, http.StatusOK, api.OpenAPIContent{Content: &content}) return nil } @@ -510,15 +512,15 @@ func (h *APIHandler) PutOpenAPISpec(w http.ResponseWriter, r *http.Request) erro } // Update document - docReq := &dto.PutAPIDocumentRequest{ - Type: constants.DocumentTypeDefinition, - Handle: constants.DocumentHandleDefinition, - DisplayName: constants.DocumentDisplayNameDefinition, - FileName: specFileName, - Content: specContent, + docReq := &dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeDefinition, + Handle: constants.DocumentHandleDefinition, + DisplayName: constants.DocumentDisplayNameDefinition, + FileName: specFileName, + Content: specContent, } - if err := h.apiDocumentService.PutDocument(docReq, orgId, updatedBy, artifactUUID); err != nil { + if err := h.apiDocumentService.UpsertDocument(docReq, orgId, updatedBy, artifactUUID); err != nil { h.slogger.Error("Failed to persist spec", "api", restApiId, "error", err) if operationsUpdated { if _, rollbackErr := h.apiService.UpdateAPIByHandle(restApiId, existingAPI, orgId, updatedBy); rollbackErr != nil { diff --git a/platform-api/internal/handler/api_document.go b/platform-api/internal/handler/api_document.go new file mode 100644 index 0000000000..096507be67 --- /dev/null +++ b/platform-api/internal/handler/api_document.go @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + + "github.com/wso2/api-platform/httpkit/httputil" + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/config" + "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/dto" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" +) + +// APIDocumentHandler serves the /apis/{apiType}/{apiId}/docs endpoints: +// create, list, read, update, delete user-authored API documents attached to any +// artifact kind. The OpenAPI DEFINITION document is managed via +// /rest-apis/{restApiId}/openapi, not here, and the service layer refuses to +// expose or mutate it through this surface. +type APIDocumentHandler struct { + service *service.APIDocumentService + identity *service.IdentityService + cfg *config.Server + slogger *slog.Logger + maxBodyBytes int64 +} + +// NewAPIDocumentHandler constructs an APIDocumentHandler. maxBodyBytes +// defaults to OpenAPISpecMaxFetchBytes (same ceiling used by the existing +// spec upload path) when zero or unset. +func NewAPIDocumentHandler(apiDocumentService *service.APIDocumentService, identity *service.IdentityService, slogger *slog.Logger, cfg *config.Server) *APIDocumentHandler { + maxBytes := constants.DefaultOpenAPISpecMaxBytes + if cfg != nil && cfg.OpenAPISpecMaxFetchBytes > 0 { + maxBytes = cfg.OpenAPISpecMaxFetchBytes + } + return &APIDocumentHandler{ + service: apiDocumentService, + identity: identity, + cfg: cfg, + slogger: slogger, + maxBodyBytes: maxBytes, + } +} + +// RegisterRoutes registers the six docs operations under a single +// {apiType} path parameter. Scope requirements are declared under the one +// matching OpenAPI path and apply to every artifact kind resolved through +// {apiType}; the artifact repository rejects an unknown kind as 404 so a +// caller cannot reach a kind the deployment did not register. +func (h *APIDocumentHandler) RegisterRoutes(mux router.Router) { + h.slogger.Debug("Registering API document routes") + base := constants.APIBasePath + "/apis/{apiType}/{apiId}/docs" + mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.ListDocuments)) + mux.HandleFunc("POST "+base, middleware.MapErrors(h.slogger, h.CreateDocument)) + mux.HandleFunc("GET "+base+"/{docId}", middleware.MapErrors(h.slogger, h.GetDocument)) + mux.HandleFunc("GET "+base+"/{docId}/content", middleware.MapErrors(h.slogger, h.GetDocumentContent)) + mux.HandleFunc("PUT "+base+"/{docId}", middleware.MapErrors(h.slogger, h.UpdateDocument)) + mux.HandleFunc("DELETE "+base+"/{docId}", middleware.MapErrors(h.slogger, h.DeleteDocument)) +} + +// fetch the org from the token, read apiId from the path, resolve the typed artifact to its UUID +func (h *APIDocumentHandler) resolveArtifactUUID(r *http.Request) (orgID, artifactUUID string, err error) { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return "", "", apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + apiID := r.PathValue("apiId") + if apiID == "" { + return "", "", apperror.ValidationFailed.New("API ID is required") + } + apiType := r.PathValue("apiType") + if apiType == "" { + return "", "", apperror.ValidationFailed.New("API type is required") + } + artifactUUID, err = h.service.ResolveArtifactUUID(apiType, apiID, orgID) + if err != nil { + return "", "", serviceError(err, "failed to resolve API "+apiID+" of type "+apiType) + } + return orgID, artifactUUID, nil +} + +// ListDocuments handles GET /apis/{apiType}/{apiId}/docs. +// Optional query param `type` filters to a single document type. +func (h *APIDocumentHandler) ListDocuments(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + rawDocType := strings.TrimSpace(r.URL.Query().Get("type")) + var docType string + if rawDocType != "" { + if normalized, ok := service.NormalizeAPIDocumentType(rawDocType); ok { + docType = normalized + } else if constants.ForbiddenOtherTypeNames[strings.ToLower(rawDocType)] { + return apperror.ValidationFailed.New("invalid document type filter") + } else { + // Custom type name — pass through as-is; the repository will query + // type = 'DOC_' which matches how custom types are stored. + docType = rawDocType + } + } + limit, offset := parsePagination(r) + + docs, total, err := h.service.GetAllApiDocuments(artifactUUID, orgID, docType, limit, offset) + if err != nil { + return serviceError(err, "failed to list documents") + } + + resp := api.APIDocumentListResponse{ + Count: len(docs), + List: docs, + Pagination: api.Pagination{ + Total: total, + Offset: offset, + Limit: limit, + }, + } + httputil.WriteJSON(w, http.StatusOK, resp) + return nil +} + +// GetDocument handles GET /apis/{apiType}/{apiId}/docs/{docId}. +// Returns document metadata only +func (h *APIDocumentHandler) GetDocument(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + docID := r.PathValue("docId") + if docID == "" { + return apperror.ValidationFailed.New("document ID is required") + } + if constants.ReservedAPIDocumentHandles[docID] { + return apperror.ValidationFailed.New("cannot access a system-managed document via this endpoint") + } + + doc, err := h.service.GetDocument(artifactUUID, docID, orgID) + if err != nil { + return serviceError(err, "failed to get document") + } + + httputil.WriteJSON(w, http.StatusOK, doc) + return nil +} + +// GetDocumentContent handles GET /apis/{apiType}/{apiId}/docs/{docId}/content. +func (h *APIDocumentHandler) GetDocumentContent(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + docID := r.PathValue("docId") + if docID == "" { + return apperror.ValidationFailed.New("document ID is required") + } + if constants.ReservedAPIDocumentHandles[docID] { + return apperror.ValidationFailed.New("cannot access a system-managed document via this endpoint") + } + + doc, content, err := h.service.GetDocumentWithContent(artifactUUID, docID, orgID, "") + if err != nil { + return serviceError(err, "failed to get document content") + } + + if len(content) == 0 { + w.WriteHeader(http.StatusNoContent) + return nil + } + + ct := "application/octet-stream" + if doc.ContentType != nil && *doc.ContentType != "" { + ct = *doc.ContentType + } + w.Header().Set("Content-Type", ct) + w.Header().Set("X-Content-Type-Options", "nosniff") + if doc.FileName != nil && *doc.FileName != "" { + fn := strings.NewReplacer(`"`, `\"`, `\`, `\\`).Replace(*doc.FileName) + w.Header().Set("Content-Disposition", `inline; filename="`+fn+`"`) + } + _, _ = w.Write(content) + return nil +} + +// CreateDocument handles POST /apis/{apiType}/{apiId}/docs. Expects a +// multipart/form-data body with: type (required), displayName (required), +// id (optional handle), and exactly one of file / inlineContent for the body. +func (h *APIDocumentHandler) CreateDocument(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + createdBy, err := resolveActorErr(r, h.identity, "create document") + if err != nil { + return err + } + + parsed, err := h.parseDocMultipart(w, r, true) + if err != nil { + return err + } + + // converts the user-supplied type to the canonical camelCase form, if valid type + normalizedType, typeOK := service.NormalizeAPIDocumentType(parsed.docType) + if !typeOK { + return apperror.ValidationFailed.New("invalid document type") + } + req := &dto.CreateAPIDocumentRequest{ + Type: normalizedType, + Handle: parsed.handle, + DisplayName: parsed.displayName, + FileName: parsed.fileName, + Content: parsed.content, + OtherTypeName: parsed.otherTypeName, + } + + handle, err := h.service.CreateApiDocument(req, orgID, createdBy, artifactUUID) + if err != nil { + return serviceError(err, "failed to create document") + } + + doc, err := h.service.GetDocument(artifactUUID, handle, orgID) + if err != nil { + return serviceError(err, "failed to load created document") + } + w.Header().Set("Location", r.URL.Path+"/"+url.PathEscape(handle)) + httputil.WriteJSON(w, http.StatusCreated, doc) + return nil +} + +// UpdateDocument handles PUT /apis/{apiType}/{apiId}/docs/{docId}. Every +// multipart field is optional — the service merges the supplied subset onto +// the stored row, leaving unmentioned fields alone. Omitting both `file` and +// `inlineContent` means a metadata-only update; the stored bytes are +// untouched. +func (h *APIDocumentHandler) UpdateDocument(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + docID := r.PathValue("docId") + if docID == "" { + return apperror.ValidationFailed.New("document ID is required") + } + updatedBy, err := resolveActorErr(r, h.identity, "update document") + if err != nil { + return err + } + + parsed, err := h.parseDocMultipart(w, r, false) + if err != nil { + return err + } + + if parsed.handleSet && parsed.handle != docID { + return apperror.ValidationFailed.New("id in request body does not match the document ID in the path") + } + + req := &dto.UpdateAPIDocumentRequest{} + if parsed.docTypeSet { + normalizedType, typeOK := service.NormalizeAPIDocumentType(parsed.docType) + if !typeOK { + return apperror.ValidationFailed.New("invalid document type") + } + req.Type = &normalizedType + req.OtherTypeName = parsed.otherTypeName + } + if parsed.displayNameSet { + req.DisplayName = &parsed.displayName + } + if parsed.content != nil { + req.Content = parsed.content + if parsed.contentTypeSet { + req.ContentType = &parsed.contentType + } + if parsed.fileNameSet { + req.FileName = &parsed.fileName + } + } else if parsed.fileNameSet { + // Explicit metadata-only filename rename. + req.FileName = &parsed.fileName + } + + if err := h.service.UpdateApiDocument(req, orgID, updatedBy, artifactUUID, docID); err != nil { + return serviceError(err, "failed to update document") + } + + doc, err := h.service.GetDocument(artifactUUID, docID, orgID) + if err != nil { + return serviceError(err, "failed to load updated document") + } + httputil.WriteJSON(w, http.StatusOK, doc) + return nil +} + +// DeleteDocument handles DELETE /apis/{apiType}/{apiId}/docs/{docId}. +func (h *APIDocumentHandler) DeleteDocument(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + docID := r.PathValue("docId") + if docID == "" { + return apperror.ValidationFailed.New("document ID is required") + } + deletedBy, err := resolveActorErr(r, h.identity, "delete document") + if err != nil { + return err + } + + if err := h.service.DeleteApiDocument(artifactUUID, docID, orgID, deletedBy); err != nil { + return serviceError(err, "failed to delete document") + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// parsedDocForm captures the shape of a parsed multipart doc request. The +// *Set flags separate "field was present (even if empty)" from "field was +// absent" so a PUT can leave a field alone vs explicitly blank it. +type parsedDocForm struct { + docType string + docTypeSet bool + otherTypeName string // only meaningful when docType == "Other" + handle string + handleSet bool + displayName string + displayNameSet bool + fileName string + fileNameSet bool + contentType string + contentTypeSet bool + // content is nil when neither `file` nor `inlineContent` was supplied, + // so a PUT can tell apart "no body change" from "replace with empty". + content []byte +} + +// parseDocMultipart parses the multipart form on r. requireContent enforces +// that exactly one of file / inlineContent was supplied (true on POST, false +// on PUT where either may be omitted for a metadata-only update). +func (h *APIDocumentHandler) parseDocMultipart(w http.ResponseWriter, r *http.Request, requireContent bool) (parsedDocForm, error) { + const multipartOverhead = 1 << 20 + r.Body = http.MaxBytesReader(w, r.Body, h.maxBodyBytes+multipartOverhead) + if err := r.ParseMultipartForm(h.maxBodyBytes); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + return parsedDocForm{}, apperror.PayloadTooLarge.New("request body exceeds the maximum allowed size") + } + return parsedDocForm{}, apperror.ValidationFailed.New("invalid multipart form") + } + + var parsed parsedDocForm + form := r.MultipartForm + if form != nil { + if vals, ok := form.Value["type"]; ok { + parsed.docTypeSet = true + if len(vals) > 0 { + parsed.docType = strings.TrimSpace(vals[0]) + } + } + if vals, ok := form.Value["id"]; ok { + parsed.handleSet = true + if len(vals) > 0 { + parsed.handle = strings.TrimSpace(vals[0]) + } + } + if vals, ok := form.Value["otherTypeName"]; ok && len(vals) > 0 { + parsed.otherTypeName = strings.TrimSpace(vals[0]) + } + if vals, ok := form.Value["displayName"]; ok { + parsed.displayNameSet = true + if len(vals) > 0 { + parsed.displayName = strings.TrimSpace(vals[0]) + } + } + } + + var inlineContent string + hasInline := false + if form != nil { + if vals, ok := form.Value["inlineContent"]; ok { + hasInline = true + if len(vals) > 0 { + inlineContent = vals[0] + } + } + } + + if requireContent && !hasInline { + return parsedDocForm{}, apperror.ValidationFailed.New("`inlineContent` is required") + } + + if hasInline { + parsed.content = []byte(inlineContent) + // The caller may supply an explicit fileName field alongside inlineContent + // (e.g. to keep or rename the stored filename on PUT). + if form != nil { + if vals, ok := form.Value["fileName"]; ok { + parsed.fileNameSet = true + if len(vals) > 0 { + parsed.fileName = sanitizeUploadFileName(strings.TrimSpace(vals[0])) + } + } + } + parsed.contentType = "text/markdown; charset=utf-8" + parsed.contentTypeSet = true + } + + return parsed, nil +} + diff --git a/platform-api/internal/handler/api_document_test.go b/platform-api/internal/handler/api_document_test.go new file mode 100644 index 0000000000..fa5f1eb175 --- /dev/null +++ b/platform-api/internal/handler/api_document_test.go @@ -0,0 +1,1067 @@ +/* + * 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. + * + */ + +// Unit tests for the /apis/{apiType}/{apiId}/docs handlers. They run the real +// handler + service stack against in-memory repository fakes and drive it +// through a real *http.ServeMux, so route registration, path-parameter +// extraction, multipart parsing and error mapping are all exercised without a +// database. The fakes (docH*) are shared with api_thumbnail_test.go. + +package handler + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" +) + +// --------------------------------------------------------------------------- +// Fakes and helpers (shared with api_thumbnail_test.go) +// --------------------------------------------------------------------------- + +const ( + docHOrg = "org-1" + docHAPI = "my-api" + docHArtifact = "artifact-uuid-1" +) + +var ( + docHKind = constants.RestApi + docHDocsBase = constants.APIBasePath + "/apis/" + docHKind + "/" + docHAPI + "/docs" + docHThumbPath = constants.APIBasePath + "/apis/" + docHKind + "/" + docHAPI + "/thumbnail" +) + +// docHRepo is an in-memory repository.DocumentRepository. The embedded +// interface is nil, so a method the handlers should never reach panics instead +// of silently returning zero values. +type docHRepo struct { + repository.DocumentRepository + + docs map[string]*model.Document // keyed by handle + + getErr, createErr, listErr, upsertErr, updateErr, deleteAPIErr, deleteErr, existsErr error + handleExists bool + listResult []*model.Document + listTotal int + + listCalls int + lastListType string + lastListLimit, lastListOffset int + created, updated, upserted []*model.Document + updateContentFlags []bool + deletedAPIHandles []string + deletedHandles, deletedTypes []string +} + +func (r *docHRepo) GetDocument(artifactUUID, handle, orgUUID, docType string) (*model.Document, error) { + if r.getErr != nil { + return nil, r.getErr + } + d, ok := r.docs[handle] + if !ok { + return nil, nil + } + if docType == "" { + // User-facing lookups never see reserved rows (mirrors the real repo). + for _, reserved := range constants.ReservedAPIDocumentTypes { + if d.Type == reserved { + return nil, nil + } + } + } else if d.Type != docType { + return nil, nil + } + return d, nil +} + +func (r *docHRepo) ListDocumentsByArtifact(artifactUUID, orgUUID, docType string, limit, offset int) ([]*model.Document, int, error) { + r.listCalls++ + r.lastListType, r.lastListLimit, r.lastListOffset = docType, limit, offset + return r.listResult, r.listTotal, r.listErr +} + +func (r *docHRepo) CreateDocument(doc *model.Document) error { + if r.createErr != nil { + return r.createErr + } + cp := *doc + r.created = append(r.created, &cp) + r.docs[cp.Handle] = &cp + return nil +} + +func (r *docHRepo) UpsertDocument(doc *model.Document) error { + if r.upsertErr != nil { + return r.upsertErr + } + cp := *doc + r.upserted = append(r.upserted, &cp) + r.docs[cp.Handle] = &cp + return nil +} + +func (r *docHRepo) UpdateApiDocument(doc *model.Document, updateContent bool) error { + if r.updateErr != nil { + return r.updateErr + } + cp := *doc + r.updated = append(r.updated, &cp) + r.updateContentFlags = append(r.updateContentFlags, updateContent) + r.docs[cp.Handle] = &cp + return nil +} + +func (r *docHRepo) DeleteApiDocument(artifactUUID, handle, orgUUID string) error { + if r.deleteAPIErr != nil { + return r.deleteAPIErr + } + r.deletedAPIHandles = append(r.deletedAPIHandles, handle) + delete(r.docs, handle) + return nil +} + +func (r *docHRepo) DeleteDocument(artifactUUID, handle, orgUUID, docType string) error { + if r.deleteErr != nil { + return r.deleteErr + } + r.deletedHandles = append(r.deletedHandles, handle) + r.deletedTypes = append(r.deletedTypes, docType) + delete(r.docs, handle) + return nil +} + +func (r *docHRepo) DocumentHandleExistsForArtifact(artifactUUID, handle string) (bool, error) { + _, ok := r.docs[handle] + return ok || r.handleExists, r.existsErr +} + +type docHAuditCall struct{ action, resourceUUID, resourceType, orgUUID, performedBy string } + +type docHAudit struct{ calls []docHAuditCall } + +func (a *docHAudit) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { + a.calls = append(a.calls, docHAuditCall{action, resourceUUID, resourceType, orgUUID, performedBy}) + return nil +} + +// docHArtifactRepo resolves exactly one API (docHAPI in docHOrg, kind docHKind). +// Any other kind behaves like an unregistered artifact kind; any other handle or +// organization behaves like a missing row. +type docHArtifactRepo struct { + repository.ArtifactRepository + err error +} + +func (a *docHArtifactRepo) GetAPIMetadataByHandleAndKind(handle, kind, orgUUID string) (*model.APIMetadata, error) { + if a.err != nil { + return nil, a.err + } + if kind != docHKind { + return nil, repository.ErrUnknownArtifactKind + } + if handle != docHAPI || orgUUID != docHOrg { + return nil, nil + } + return &model.APIMetadata{ID: docHArtifact, Handle: handle, Kind: kind, OrganizationID: orgUUID}, nil +} + +// docHFailingIdentityRepo makes every actor lookup fail. +type docHFailingIdentityRepo struct{ repository.UserIdentityMappingRepository } + +func (docHFailingIdentityRepo) GetOrCreateUUID(string) (string, error) { + return "", errors.New("identity store unavailable") +} + +type docHEnv struct { + mux *http.ServeMux + docs *docHRepo + audit *docHAudit + artifacts *docHArtifactRepo +} + +func newDocHEnv(t *testing.T, cfg *config.Server) *docHEnv { + t.Helper() + return newDocHEnvWithIdentity(t, cfg, nil) +} + +// newDocHEnvWithIdentity wires both the docs and the thumbnail handler onto one +// mux. A nil identityRepo is fine for requests that carry no actor claim: the +// identity service then mints an anonymous UUID without touching the repo. +func newDocHEnvWithIdentity(t *testing.T, cfg *config.Server, identityRepo repository.UserIdentityMappingRepository) *docHEnv { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + env := &docHEnv{ + docs: &docHRepo{docs: map[string]*model.Document{}}, + audit: &docHAudit{}, + artifacts: &docHArtifactRepo{}, + } + svc := service.NewAPIDocumentService(env.docs, env.artifacts, env.audit, logger) + identity := service.NewIdentityService(identityRepo) + env.mux = http.NewServeMux() + NewAPIDocumentHandler(svc, identity, logger, cfg).RegisterRoutes(env.mux) + NewAPIThumbnailHandler(svc, identity, logger, cfg).RegisterRoutes(env.mux) + return env +} + +// seed stores a document under the test artifact/org. +func (e *docHEnv) seed(d *model.Document) *model.Document { + d.ArtifactUUID = docHArtifact + d.OrganizationUUID = docHOrg + e.docs.docs[d.Handle] = d + return d +} + +// do issues one request. An empty org omits the organization claim; an empty +// user omits the actor claim. +func (e *docHEnv) do(t *testing.T, method, target, org, user string, body io.Reader, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, body) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if org != "" { + req = middleware.WithOrganization(req, org) + } + if user != "" { + req = middleware.WithUserID(req, user) + } + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + return rec +} + +// get is shorthand for an authenticated bodiless request in docHOrg. +func (e *docHEnv) get(t *testing.T, method, target string) *httptest.ResponseRecorder { + t.Helper() + return e.do(t, method, target, docHOrg, "", nil, "") +} + +type docHFile struct { + field, name string + content []byte +} + +func docHForm(t *testing.T, fields map[string]string, files ...docHFile) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("write field %q: %v", k, err) + } + } + for _, f := range files { + part, err := w.CreateFormFile(f.field, f.name) + if err != nil { + t.Fatalf("create form file %q: %v", f.field, err) + } + if _, err := part.Write(f.content); err != nil { + t.Fatalf("write form file %q: %v", f.field, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + return &buf, w.FormDataContentType() +} + +// form sends a multipart request in docHOrg. +func (e *docHEnv) form(t *testing.T, method, target string, fields map[string]string, files ...docHFile) *httptest.ResponseRecorder { + t.Helper() + body, ct := docHForm(t, fields, files...) + return e.do(t, method, target, docHOrg, "", body, ct) +} + +func docHAssertError(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantCode string) { + t.Helper() + if rec.Code != wantStatus { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, wantStatus, rec.Body.String()) + } + var body struct { + Status string `json:"status"` + Code string `json:"code"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body: %v; body: %s", err, rec.Body.String()) + } + if body.Code != wantCode { + t.Fatalf("code = %q, want %q; body: %s", body.Code, wantCode, rec.Body.String()) + } + if body.Status != "error" { + t.Fatalf("status field = %q, want %q", body.Status, "error") + } +} + +func docHDecodeMetadata(t *testing.T, rec *httptest.ResponseRecorder) api.APIDocumentMetadata { + t.Helper() + var md api.APIDocumentMetadata + if err := json.Unmarshal(rec.Body.Bytes(), &md); err != nil { + t.Fatalf("decode metadata: %v; body: %s", err, rec.Body.String()) + } + return md +} + +func docHSampleDoc() *model.Document { + return &model.Document{ + ID: "doc-uuid-1", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + Handle: "guide", + DisplayName: "Guide", + FileName: "guide.md", + ContentType: "text/markdown; charset=utf-8", + Content: []byte("# Guide"), + } +} + +func docHAuditLast(t *testing.T, a *docHAudit) docHAuditCall { + t.Helper() + if len(a.calls) == 0 { + t.Fatalf("no audit entry recorded") + } + return a.calls[len(a.calls)-1] +} + +// --------------------------------------------------------------------------- +// Construction and routing +// --------------------------------------------------------------------------- + +func TestNewAPIDocumentHandler_MaxBodyBytes(t *testing.T) { + cases := []struct { + name string + cfg *config.Server + want int64 + }{ + {"nil config uses the default", nil, constants.DefaultOpenAPISpecMaxBytes}, + {"zero value uses the default", &config.Server{}, constants.DefaultOpenAPISpecMaxBytes}, + {"configured value wins", &config.Server{OpenAPISpecMaxFetchBytes: 4096}, 4096}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := NewAPIDocumentHandler(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), tc.cfg) + if h.maxBodyBytes != tc.want { + t.Errorf("maxBodyBytes = %d, want %d", h.maxBodyBytes, tc.want) + } + }) + } +} + +func TestAPIDocumentRoutes_UnsupportedMethodsAreRejected(t *testing.T) { + env := newDocHEnv(t, nil) + cases := []struct{ method, target string }{ + {http.MethodPatch, docHDocsBase}, + {http.MethodDelete, docHDocsBase}, + {http.MethodPost, docHDocsBase + "/guide"}, + {http.MethodPut, docHDocsBase + "/guide/content"}, + } + for _, tc := range cases { + t.Run(tc.method+" "+tc.target, func(t *testing.T) { + if rec := env.get(t, tc.method, tc.target); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } + }) + } +} + +// A request without an organization claim is rejected before any repository is +// consulted, on every operation. +func TestAPIDocumentHandlers_MissingOrganizationIsUnauthorized(t *testing.T) { + env := newDocHEnv(t, nil) + cases := []struct{ name, method, target string }{ + {"list", http.MethodGet, docHDocsBase}, + {"create", http.MethodPost, docHDocsBase}, + {"get", http.MethodGet, docHDocsBase + "/guide"}, + {"content", http.MethodGet, docHDocsBase + "/guide/content"}, + {"update", http.MethodPut, docHDocsBase + "/guide"}, + {"delete", http.MethodDelete, docHDocsBase + "/guide"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := env.do(t, tc.method, tc.target, "", "", nil, "") + docHAssertError(t, rec, http.StatusUnauthorized, apperror.CodeCommonUnauthorized) + }) + } +} + +// Unknown kinds, unknown APIs and APIs that belong to another organization all +// collapse to the same 404, so a caller cannot probe for what exists. +func TestAPIDocumentHandlers_UnresolvableArtifactIsNotFound(t *testing.T) { + env := newDocHEnv(t, nil) + apiBase := constants.APIBasePath + "/apis/" + cases := []struct{ name, target, org string }{ + {"unknown kind", apiBase + "bogus-kind/" + docHAPI + "/docs", docHOrg}, + {"unknown api", apiBase + docHKind + "/no-such-api/docs", docHOrg}, + {"api in another organization", docHDocsBase, "org-2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := env.do(t, http.MethodGet, tc.target, tc.org, "", nil, "") + docHAssertError(t, rec, http.StatusNotFound, apperror.CodeCommonNotFound) + }) + } +} + +// An artifact lookup that fails for a reason other than "not found" must not +// leak its cause: it becomes a sterile 500. +func TestAPIDocumentHandlers_ArtifactLookupFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.artifacts.err = errors.New("connection refused") + rec := env.get(t, http.MethodGet, docHDocsBase) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + if strings.Contains(rec.Body.String(), "connection refused") { + t.Errorf("internal error text leaked to the client: %s", rec.Body.String()) + } +} + +// --------------------------------------------------------------------------- +// GET /docs +// --------------------------------------------------------------------------- + +func TestListDocuments_ReturnsPageWithDecodedTypes(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.listResult = []*model.Document{ + {Handle: "guide", Type: "DOC_HowTo", DisplayName: "Guide", FileName: "guide.md"}, + {Handle: "faq", Type: "DOC_FAQ", DisplayName: "FAQ"}, + } + env.docs.listTotal = 7 + + rec := env.get(t, http.MethodGet, docHDocsBase+"?limit=2&offset=4") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + var resp api.APIDocumentListResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Count != 2 || len(resp.List) != 2 { + t.Fatalf("count = %d, len(list) = %d, want 2/2", resp.Count, len(resp.List)) + } + if resp.Pagination.Total != 7 || resp.Pagination.Offset != 4 || resp.Pagination.Limit != 2 { + t.Errorf("pagination = %+v, want total=7 offset=4 limit=2", resp.Pagination) + } + // The DOC_ storage prefix must never reach the wire. + if resp.List[0].Id != "guide" || resp.List[0].Type != "HowTo" { + t.Errorf("first item = {%s %s}, want {guide HowTo}", resp.List[0].Id, resp.List[0].Type) + } + if resp.List[1].Id != "faq" || resp.List[1].Type != "FAQ" { + t.Errorf("second item = {%s %s}, want {faq FAQ}", resp.List[1].Id, resp.List[1].Type) + } + if env.docs.lastListLimit != 2 || env.docs.lastListOffset != 4 { + t.Errorf("repo got limit=%d offset=%d, want 2/4", env.docs.lastListLimit, env.docs.lastListOffset) + } +} + +func TestListDocuments_EmptyListIsAnArrayNotNull(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.get(t, http.MethodGet, docHDocsBase) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + if got := strings.TrimSpace(string(raw["list"])); got != "[]" { + t.Errorf("list = %s, want an empty JSON array rather than null", got) + } +} + +func TestListDocuments_TypeFilter(t *testing.T) { + cases := []struct { + name, query string + wantType string + wantStatus int + }{ + {"no filter", "", "", http.StatusOK}, + {"fixed type is canonicalised", "?type=howto", "HowTo", http.StatusOK}, + {"surrounding whitespace is trimmed", "?type=%20SupportForum%20", "SupportForum", http.StatusOK}, + {"Other is canonicalised", "?type=OTHER", "Other", http.StatusOK}, + {"custom type passes through verbatim", "?type=FAQ", "FAQ", http.StatusOK}, + {"reserved DEFINITION is rejected", "?type=DEFINITION", "", http.StatusBadRequest}, + {"reserved THUMBNAIL is rejected in any case", "?type=thumbnail", "", http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.get(t, http.MethodGet, docHDocsBase+tc.query) + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantStatus != http.StatusOK { + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + if env.docs.listCalls != 0 { + t.Errorf("repository was queried despite an invalid filter") + } + return + } + if env.docs.lastListType != tc.wantType { + t.Errorf("repo filter = %q, want %q", env.docs.lastListType, tc.wantType) + } + }) + } +} + +func TestListDocuments_PaginationIsClamped(t *testing.T) { + cases := []struct { + name, query string + wantLimit, wantOffset int + }{ + {"defaults", "", 20, 0}, + {"limit above the maximum is clamped", "?limit=1000", 100, 0}, + {"limit below the minimum is clamped", "?limit=0", 1, 0}, + {"negative offset falls back to zero", "?offset=-5", 20, 0}, + {"malformed values fall back to defaults", "?limit=abc&offset=xyz", 20, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.get(t, http.MethodGet, docHDocsBase+tc.query) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if env.docs.lastListLimit != tc.wantLimit || env.docs.lastListOffset != tc.wantOffset { + t.Errorf("repo got limit=%d offset=%d, want %d/%d", + env.docs.lastListLimit, env.docs.lastListOffset, tc.wantLimit, tc.wantOffset) + } + }) + } +} + +func TestListDocuments_RepositoryFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.listErr = errors.New("db down") + rec := env.get(t, http.MethodGet, docHDocsBase) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +// --------------------------------------------------------------------------- +// GET /docs/{docId} +// --------------------------------------------------------------------------- + +func TestGetDocument_ReturnsMetadataOnly(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.get(t, http.MethodGet, docHDocsBase+"/guide") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + md := docHDecodeMetadata(t, rec) + if md.Id != "guide" || md.Type != "HowTo" || md.DisplayName != "Guide" { + t.Errorf("metadata = %+v, want id=guide type=HowTo displayName=Guide", md) + } + if md.FileName == nil || *md.FileName != "guide.md" { + t.Errorf("fileName = %v, want guide.md", md.FileName) + } + if strings.Contains(rec.Body.String(), "# Guide") { + t.Errorf("metadata response must not carry document bytes: %s", rec.Body.String()) + } +} + +func TestGetDocument_NotFound(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.get(t, http.MethodGet, docHDocsBase+"/missing") + docHAssertError(t, rec, http.StatusNotFound, apperror.CodeCommonNotFound) +} + +func TestGetDocument_ReservedHandlesAreRefused(t *testing.T) { + for _, handle := range []string{constants.DocumentHandleDefinition, constants.DocumentHandleThumbnail} { + t.Run(handle, func(t *testing.T) { + env := newDocHEnv(t, nil) + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/"+handle), + http.StatusBadRequest, apperror.CodeCommonValidationFailed) + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/"+handle+"/content"), + http.StatusBadRequest, apperror.CodeCommonValidationFailed) + }) + } +} + +// A reserved-type row stored under an ordinary handle is still invisible here. +func TestGetDocument_ReservedTypeRowIsHidden(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(&model.Document{Handle: "sneaky", Type: constants.DocumentTypeDefinition, DisplayName: "Spec"}) + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/sneaky"), + http.StatusNotFound, apperror.CodeCommonNotFound) +} + +func TestGetDocument_RepositoryFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.getErr = errors.New("db down") + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/guide"), + http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +// --------------------------------------------------------------------------- +// GET /docs/{docId}/content +// --------------------------------------------------------------------------- + +func TestGetDocumentContent_StreamsStoredBytes(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.get(t, http.MethodGet, docHDocsBase+"/guide/content") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Body.String() != "# Guide" { + t.Errorf("body = %q, want %q", rec.Body.String(), "# Guide") + } + if got := rec.Header().Get("Content-Type"); got != "text/markdown; charset=utf-8" { + t.Errorf("Content-Type = %q, want the stored type", got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q, want nosniff", got) + } + if got := rec.Header().Get("Content-Disposition"); got != `inline; filename="guide.md"` { + t.Errorf("Content-Disposition = %q", got) + } +} + +func TestGetDocumentContent_HeaderFallbacksAndEscaping(t *testing.T) { + t.Run("missing content type falls back to octet-stream", func(t *testing.T) { + env := newDocHEnv(t, nil) + d := docHSampleDoc() + d.ContentType = "" + env.seed(d) + rec := env.get(t, http.MethodGet, docHDocsBase+"/guide/content") + if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" { + t.Errorf("Content-Type = %q, want application/octet-stream", got) + } + }) + t.Run("missing file name omits Content-Disposition", func(t *testing.T) { + env := newDocHEnv(t, nil) + d := docHSampleDoc() + d.FileName = "" + env.seed(d) + rec := env.get(t, http.MethodGet, docHDocsBase+"/guide/content") + if got := rec.Header().Get("Content-Disposition"); got != "" { + t.Errorf("Content-Disposition = %q, want none", got) + } + }) + t.Run("quotes and backslashes in the file name are escaped", func(t *testing.T) { + env := newDocHEnv(t, nil) + d := docHSampleDoc() + d.FileName = `we"ird\name.md` + env.seed(d) + rec := env.get(t, http.MethodGet, docHDocsBase+"/guide/content") + want := `inline; filename="we\"ird\\name.md"` + if got := rec.Header().Get("Content-Disposition"); got != want { + t.Errorf("Content-Disposition = %q, want %q", got, want) + } + }) +} + +func TestGetDocumentContent_EmptyBodyIsNoContent(t *testing.T) { + env := newDocHEnv(t, nil) + d := docHSampleDoc() + d.Content = nil + env.seed(d) + rec := env.get(t, http.MethodGet, docHDocsBase+"/guide/content") + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", rec.Code) + } +} + +func TestGetDocumentContent_NotFoundAndFailures(t *testing.T) { + env := newDocHEnv(t, nil) + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/missing/content"), + http.StatusNotFound, apperror.CodeCommonNotFound) + + env.docs.getErr = errors.New("db down") + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/guide/content"), + http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +// --------------------------------------------------------------------------- +// POST /docs +// --------------------------------------------------------------------------- + +func TestCreateDocument_InlineContent(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "howto", + "displayName": "Getting Started", + "id": "getting-started", + "inlineContent": "# Hello", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String()) + } + if got, want := rec.Header().Get("Location"), docHDocsBase+"/getting-started"; got != want { + t.Errorf("Location = %q, want %q", got, want) + } + md := docHDecodeMetadata(t, rec) + if md.Id != "getting-started" || md.Type != "HowTo" || md.DisplayName != "Getting Started" { + t.Errorf("metadata = %+v", md) + } + + if len(env.docs.created) != 1 { + t.Fatalf("created %d documents, want 1", len(env.docs.created)) + } + got := env.docs.created[0] + if got.Type != "DOC_HowTo" { + t.Errorf("stored type = %q, want DOC_HowTo", got.Type) + } + if got.ContentType != "text/markdown; charset=utf-8" || string(got.Content) != "# Hello" { + t.Errorf("stored content = %q (%s)", got.Content, got.ContentType) + } + if got.FileName != "getting-started.md" { + t.Errorf("stored fileName = %q, want getting-started.md", got.FileName) + } + if got.ArtifactUUID != docHArtifact || got.OrganizationUUID != docHOrg { + t.Errorf("stored under artifact=%q org=%q", got.ArtifactUUID, got.OrganizationUUID) + } + if call := docHAuditLast(t, env.audit); call.action != "CREATE" || call.resourceType != "api_document" { + t.Errorf("audit = %+v, want CREATE api_document", call) + } +} + +func TestCreateDocument_GeneratesHandleFromDisplayName(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "Samples", "displayName": "Release Notes", "inlineContent": "x", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String()) + } + md := docHDecodeMetadata(t, rec) + if md.Id != "release-notes" { + t.Errorf("generated id = %q, want release-notes", md.Id) + } + if got := rec.Header().Get("Location"); !strings.HasSuffix(got, "/"+md.Id) { + t.Errorf("Location = %q, want a suffix of /%s", got, md.Id) + } +} + +func TestCreateDocument_CustomOtherType(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "Other", "otherTypeName": "FAQ", "displayName": "Common Questions", "inlineContent": "Q&A", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String()) + } + if got := env.docs.created[0].Type; got != "DOC_FAQ" { + t.Errorf("stored type = %q, want DOC_FAQ", got) + } + if md := docHDecodeMetadata(t, rec); md.Type != "FAQ" { + t.Errorf("response type = %q, want the bare custom name FAQ", md.Type) + } +} + +func TestCreateDocument_RejectsInvalidRequests(t *testing.T) { + cases := []struct { + name string + fields map[string]string + }{ + {"inlineContent is required", map[string]string{"type": "HowTo", "displayName": "x"}}, + {"type is required", map[string]string{"displayName": "x", "inlineContent": "x"}}, + {"unknown type", map[string]string{"type": "Bogus", "displayName": "x", "inlineContent": "x"}}, + {"reserved type", map[string]string{"type": "DEFINITION", "displayName": "x", "inlineContent": "x"}}, + {"displayName is required", map[string]string{"type": "HowTo", "inlineContent": "x"}}, + {"blank displayName", map[string]string{"type": "HowTo", "displayName": " ", "inlineContent": "x"}}, + {"forbidden custom type name", map[string]string{"type": "Other", "otherTypeName": "HowTo", "displayName": "x", "inlineContent": "x"}}, + {"reserved handle", map[string]string{"type": "HowTo", "displayName": "x", "id": constants.DocumentHandleThumbnail, "inlineContent": "x"}}, + {"malformed handle", map[string]string{"type": "HowTo", "displayName": "x", "id": "Not A Handle", "inlineContent": "x"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPost, docHDocsBase, tc.fields) + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + if len(env.docs.created) != 0 { + t.Errorf("a document was stored for an invalid request") + } + }) + } +} + +func TestCreateDocument_DuplicateHandleIsConflict(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "HowTo", "displayName": "Another", "id": "guide", "inlineContent": "x", + }) + docHAssertError(t, rec, http.StatusConflict, apperror.CodeCommonConflict) +} + +// Two requests racing past the existence check are caught by the DB unique +// index and must still surface as a 409. +func TestCreateDocument_UniqueViolationIsConflict(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.createErr = errors.New("UNIQUE constraint failed: api_documents.handle") + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "HowTo", "displayName": "Racy", "inlineContent": "x", + }) + docHAssertError(t, rec, http.StatusConflict, apperror.CodeCommonConflict) +} + +func TestCreateDocument_RepositoryFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.createErr = errors.New("disk full") + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "HowTo", "displayName": "Doc", "inlineContent": "x", + }) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +func TestCreateDocument_NotMultipartIsBadRequest(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.do(t, http.MethodPost, docHDocsBase, docHOrg, "", strings.NewReader(`{"type":"HowTo"}`), "application/json") + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestCreateDocument_OversizedBodyIsPayloadTooLarge(t *testing.T) { + env := newDocHEnv(t, &config.Server{OpenAPISpecMaxFetchBytes: 16}) + rec := env.form(t, http.MethodPost, docHDocsBase, map[string]string{ + "type": "HowTo", + "displayName": "Huge", + "inlineContent": strings.Repeat("a", (1<<20)+1024), // beyond limit + the 1 MiB multipart allowance + }) + docHAssertError(t, rec, http.StatusRequestEntityTooLarge, apperror.CodeCommonPayloadTooLarge) +} + +// When the caller's identity cannot be mapped to an internal user id the write +// fails closed instead of storing an unattributed row. +func TestDocumentWrites_UnresolvableActorIsInternalError(t *testing.T) { + env := newDocHEnvWithIdentity(t, nil, docHFailingIdentityRepo{}) + env.seed(docHSampleDoc()) + + body, ct := docHForm(t, map[string]string{"type": "HowTo", "displayName": "Doc", "inlineContent": "x"}) + rec := env.do(t, http.MethodPost, docHDocsBase, docHOrg, "alice", body, ct) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + + body, ct = docHForm(t, map[string]string{"displayName": "Renamed"}) + rec = env.do(t, http.MethodPut, docHDocsBase+"/guide", docHOrg, "alice", body, ct) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + + rec = env.do(t, http.MethodDelete, docHDocsBase+"/guide", docHOrg, "alice", nil, "") + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + + if len(env.docs.created)+len(env.docs.updated)+len(env.docs.deletedAPIHandles) != 0 { + t.Errorf("a write reached the repository despite the identity failure") + } +} + +// --------------------------------------------------------------------------- +// PUT /docs/{docId} +// --------------------------------------------------------------------------- + +func TestUpdateDocument_MetadataOnlyKeepsStoredBytes(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.form(t, http.MethodPut, docHDocsBase+"/guide", map[string]string{"displayName": " Guide v2 "}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + if len(env.docs.updated) != 1 { + t.Fatalf("got %d updates, want 1", len(env.docs.updated)) + } + if env.docs.updateContentFlags[0] { + t.Errorf("updateContent = true for a metadata-only PUT; stored bytes would be overwritten") + } + if got := env.docs.updated[0].DisplayName; got != "Guide v2" { + t.Errorf("stored displayName = %q, want the trimmed value", got) + } + if md := docHDecodeMetadata(t, rec); md.DisplayName != "Guide v2" { + t.Errorf("response displayName = %q", md.DisplayName) + } + if call := docHAuditLast(t, env.audit); call.action != "UPDATE" { + t.Errorf("audit action = %q, want UPDATE", call.action) + } +} + +func TestUpdateDocument_ReplacesContent(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.form(t, http.MethodPut, docHDocsBase+"/guide", map[string]string{ + "inlineContent": "# Rewritten", "fileName": "../notes/rewritten.md", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + if !env.docs.updateContentFlags[0] { + t.Errorf("updateContent = false although new bytes were supplied") + } + got := env.docs.updated[0] + if string(got.Content) != "# Rewritten" || got.ContentType != "text/markdown; charset=utf-8" { + t.Errorf("stored content = %q (%s)", got.Content, got.ContentType) + } + if got.FileName != "rewritten.md" { + t.Errorf("stored fileName = %q, want the sanitised base name rewritten.md", got.FileName) + } +} + +func TestUpdateDocument_CanEmptyTheContent(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.form(t, http.MethodPut, docHDocsBase+"/guide", map[string]string{"inlineContent": ""}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + if !env.docs.updateContentFlags[0] { + t.Errorf("an explicitly empty inlineContent must replace the stored bytes") + } + if len(env.docs.updated[0].Content) != 0 { + t.Errorf("stored content = %q, want empty", env.docs.updated[0].Content) + } +} + +func TestUpdateDocument_ChangesType(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.form(t, http.MethodPut, docHDocsBase+"/guide", map[string]string{ + "type": "Other", "otherTypeName": "FAQ", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + if got := env.docs.updated[0].Type; got != "DOC_FAQ" { + t.Errorf("stored type = %q, want DOC_FAQ", got) + } + if md := docHDecodeMetadata(t, rec); md.Type != "FAQ" { + t.Errorf("response type = %q, want FAQ", md.Type) + } +} + +func TestUpdateDocument_RejectsInvalidRequests(t *testing.T) { + cases := []struct { + name, target string + fields map[string]string + }{ + {"id differs from the path", "/guide", map[string]string{"id": "other-doc"}}, + {"blank id never matches the path", "/guide", map[string]string{"id": " "}}, + {"unknown type", "/guide", map[string]string{"type": "Bogus"}}, + {"empty type", "/guide", map[string]string{"type": ""}}, + {"forbidden custom type name", "/guide", map[string]string{"type": "Other", "otherTypeName": "Samples"}}, + {"blank displayName", "/guide", map[string]string{"displayName": " "}}, + {"reserved handle", "/" + constants.DocumentHandleDefinition, map[string]string{"displayName": "x"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + rec := env.form(t, http.MethodPut, docHDocsBase+tc.target, tc.fields) + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + if len(env.docs.updated) != 0 { + t.Errorf("an update reached the repository for an invalid request") + } + }) + } +} + +func TestUpdateDocument_MatchingIdIsAccepted(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + rec := env.form(t, http.MethodPut, docHDocsBase+"/guide", map[string]string{"id": "guide", "displayName": "Same id"}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } +} + +func TestUpdateDocument_NotFound(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPut, docHDocsBase+"/missing", map[string]string{"displayName": "x"}) + docHAssertError(t, rec, http.StatusNotFound, apperror.CodeCommonNotFound) +} + +func TestUpdateDocument_NotMultipartIsBadRequest(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + rec := env.do(t, http.MethodPut, docHDocsBase+"/guide", docHOrg, "", strings.NewReader("displayName=x"), "application/x-www-form-urlencoded") + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestUpdateDocument_RepositoryFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + env.docs.updateErr = errors.New("db down") + rec := env.form(t, http.MethodPut, docHDocsBase+"/guide", map[string]string{"displayName": "x"}) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +// --------------------------------------------------------------------------- +// DELETE /docs/{docId} +// --------------------------------------------------------------------------- + +func TestDeleteDocument_Success(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + + rec := env.get(t, http.MethodDelete, docHDocsBase+"/guide") + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String()) + } + if len(env.docs.deletedAPIHandles) != 1 || env.docs.deletedAPIHandles[0] != "guide" { + t.Errorf("deleted handles = %v, want [guide]", env.docs.deletedAPIHandles) + } + if call := docHAuditLast(t, env.audit); call.action != "DELETE" || call.resourceType != "api_document" || call.resourceUUID != docHArtifact { + t.Errorf("audit = %+v, want DELETE api_document on the artifact", call) + } + // And the document is really gone. + docHAssertError(t, env.get(t, http.MethodGet, docHDocsBase+"/guide"), http.StatusNotFound, apperror.CodeCommonNotFound) +} + +func TestDeleteDocument_Failures(t *testing.T) { + t.Run("not found", func(t *testing.T) { + env := newDocHEnv(t, nil) + docHAssertError(t, env.get(t, http.MethodDelete, docHDocsBase+"/missing"), + http.StatusNotFound, apperror.CodeCommonNotFound) + }) + t.Run("row vanishes between lookup and delete", func(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + env.docs.deleteAPIErr = sql.ErrNoRows + docHAssertError(t, env.get(t, http.MethodDelete, docHDocsBase+"/guide"), + http.StatusNotFound, apperror.CodeCommonNotFound) + }) + t.Run("reserved handle", func(t *testing.T) { + env := newDocHEnv(t, nil) + docHAssertError(t, env.get(t, http.MethodDelete, docHDocsBase+"/"+constants.DocumentHandleThumbnail), + http.StatusBadRequest, apperror.CodeCommonValidationFailed) + if len(env.docs.deletedAPIHandles) != 0 { + t.Errorf("a reserved handle reached the repository") + } + }) + t.Run("repository failure", func(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(docHSampleDoc()) + env.docs.deleteAPIErr = errors.New("db down") + docHAssertError(t, env.get(t, http.MethodDelete, docHDocsBase+"/guide"), + http.StatusInternalServerError, apperror.CodeCommonInternalError) + }) +} diff --git a/platform-api/internal/handler/api_openapi_test.go b/platform-api/internal/handler/api_openapi_test.go new file mode 100644 index 0000000000..05a35110b1 --- /dev/null +++ b/platform-api/internal/handler/api_openapi_test.go @@ -0,0 +1,694 @@ +/* + * 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. + * + */ + +// Handler-level unit tests for the three openapi routes on api.go: +// POST /api/v0.9/rest-apis/import-openapi +// GET /api/v0.9/rest-apis/{restApiId}/openapi +// PUT /api/v0.9/rest-apis/{restApiId}/openapi + +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wso2/api-platform/platform-api/api" + "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/apperror" + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" + "github.com/wso2/api-platform/platform-api/internal/service" + "github.com/wso2/api-platform/platform-api/internal/utils" +) + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ( + oaOrg = "org-1" + oaProjectID = "project-uuid-1" + oaProjectHand = "retail" + oaAPIHandle = "orders-api" + oaAPIUUID = "api-uuid-1" + oaDisplayName = "Orders API" + oaVersion = "v1.0" + oaContext = "/orders" + oaMainUpstream = `{"main":{"url":"http://upstream:3000"}}` +) + +const validOpenAPI = `openapi: 3.0.0 +info: + title: Orders API + version: 1.0.0 +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK + post: + summary: Create pet + responses: + '201': + description: Created +` + +const updatedOpenAPI = `openapi: 3.0.0 +info: + title: Orders API + version: 2.0.0 +paths: + /animals: + get: + summary: List animals + responses: + '200': + description: OK +` + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +// oaAPIRepo implements just enough of repository.APIRepository for the +// openapi handlers. The embedded interface is nil so any method the handler +// shouldn't reach will panic with a diagnosable error. +type oaAPIRepo struct { + repository.APIRepository + + // Call knobs. + handleExists bool + handleExistsErr error + nameVersionExists bool + getMetadataErr error + getByUUIDErr error + createErr error + updateErr error + + // Storage (write-through, so a Create → GetAPIByUUID round-trip works). + byUUID map[string]*model.API + byHandle map[string]*model.APIMetadata + createdID string +} + +func newOAAPIRepo() *oaAPIRepo { + return &oaAPIRepo{ + byUUID: map[string]*model.API{}, + byHandle: map[string]*model.APIMetadata{}, + } +} + +func (r *oaAPIRepo) CheckAPIExistsByHandleInOrganization(handle, orgUUID string) (bool, error) { + return r.handleExists, r.handleExistsErr +} + +func (r *oaAPIRepo) CheckAPIExistsByNameAndVersionInOrganization(name, version, orgUUID, exclude string) (bool, error) { + return r.nameVersionExists, nil +} + +func (r *oaAPIRepo) CreateAPI(apiModel *model.API) error { + if r.createErr != nil { + return r.createErr + } + // The real repo generates the UUID. For deterministic tests, use a fixed one. + apiModel.ID = oaAPIUUID + r.createdID = apiModel.ID + cp := *apiModel + r.byUUID[apiModel.ID] = &cp + r.byHandle[apiModel.Handle] = &model.APIMetadata{ + ID: apiModel.ID, + Handle: apiModel.Handle, + Kind: apiModel.Kind, + OrganizationID: apiModel.OrganizationID, + } + return nil +} + +func (r *oaAPIRepo) GetAPIByUUID(apiUUID, orgUUID string) (*model.API, error) { + if r.getByUUIDErr != nil { + return nil, r.getByUUIDErr + } + a, ok := r.byUUID[apiUUID] + if !ok { + return nil, nil + } + return a, nil +} + +func (r *oaAPIRepo) GetAPIMetadataByHandle(handle, orgUUID string) (*model.APIMetadata, error) { + if r.getMetadataErr != nil { + return nil, r.getMetadataErr + } + md, ok := r.byHandle[handle] + if !ok { + return nil, nil + } + return md, nil +} + +func (r *oaAPIRepo) UpdateAPI(apiModel *model.API) error { + if r.updateErr != nil { + return r.updateErr + } + cp := *apiModel + r.byUUID[apiModel.ID] = &cp + return nil +} + +func (r *oaAPIRepo) DeleteAPI(apiUUID, orgUUID string) error { + delete(r.byUUID, apiUUID) + return nil +} + +// oaProjectRepo stocks exactly one project — retail/project-uuid-1. +type oaProjectRepo struct { + repository.ProjectRepository + byHandleErr, byUUIDErr error +} + +func (r *oaProjectRepo) GetProjectByHandleAndOrgID(handle, orgUUID string) (*model.Project, error) { + if r.byHandleErr != nil { + return nil, r.byHandleErr + } + if handle != oaProjectHand || orgUUID != oaOrg { + return nil, nil + } + return &model.Project{ID: oaProjectID, Handle: oaProjectHand, OrganizationID: oaOrg}, nil +} + +func (r *oaProjectRepo) GetProjectByUUID(uuid string) (*model.Project, error) { + if r.byUUIDErr != nil { + return nil, r.byUUIDErr + } + if uuid != oaProjectID { + return nil, nil + } + return &model.Project{ID: oaProjectID, Handle: oaProjectHand, OrganizationID: oaOrg}, nil +} + +// oaIdentityRepo satisfies IdentityService without reaching any database. +type oaIdentityRepo struct { + repository.UserIdentityMappingRepository + err error +} + +func (r oaIdentityRepo) GetOrCreateUUID(identity string) (string, error) { + if r.err != nil { + return "", r.err + } + return "internal-" + identity, nil +} + +func (r oaIdentityRepo) GetSubByUUID(uuid string) (string, bool, error) { + return strings.TrimPrefix(uuid, "internal-"), true, nil +} + +func (r oaIdentityRepo) GetSubsByUUIDs(uuids []string) (map[string]string, error) { + out := map[string]string{} + for _, u := range uuids { + out[u] = strings.TrimPrefix(u, "internal-") + } + return out, nil +} + +// oaAudit records everything but never fails. +type oaAudit struct{ calls int } + +func (a *oaAudit) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { + a.calls++ + return nil +} + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +type oaEnv struct { + mux *http.ServeMux + apiRepo *oaAPIRepo + projectRepo *oaProjectRepo + docRepo *docHRepo + artifactRepo *docHArtifactRepo + audit *oaAudit + docAudit *docHAudit +} + +func newOAEnv(t *testing.T) *oaEnv { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + env := &oaEnv{ + apiRepo: newOAAPIRepo(), + projectRepo: &oaProjectRepo{}, + docRepo: &docHRepo{docs: map[string]*model.Document{}}, + artifactRepo: &docHArtifactRepo{}, + audit: &oaAudit{}, + docAudit: &docHAudit{}, + } + identitySvc := service.NewIdentityService(oaIdentityRepo{}) + apiService := service.NewAPIService(env.apiRepo, env.projectRepo, + nil /* orgRepo — not reached by these three handlers in the covered paths */, + nil /* gatewayRepo */, nil /* deploymentRepo */, nil /* subscriptionPlanRepo */, nil /* customPolicyRepo */, + nil /* gatewayEventsService */, &utils.APIUtil{}, logger, env.audit, identitySvc) + apiDocumentService := service.NewAPIDocumentService(env.docRepo, env.artifactRepo, env.docAudit, logger) + + env.mux = http.NewServeMux() + NewAPIHandler(apiService, identitySvc, apiDocumentService, logger, &config.Server{}).RegisterRoutes(env.mux) + return env +} + +// seedExistingAPI makes the artifact resolvable (needed for GET /openapi and PUT /openapi). +// `readOnly=true` marks the API as gateway-originated (`origin=gateway_api`), +// which the service-layer converter maps to `RESTAPI.ReadOnly = true` so the +// PUT /openapi handler takes the validate-only branch. +func (e *oaEnv) seedExistingAPI(readOnly bool) { + origin := constants.OriginCP + if readOnly { + origin = constants.OriginDP + } + e.apiRepo.byUUID[oaAPIUUID] = &model.API{ + ID: oaAPIUUID, + Handle: oaAPIHandle, + Kind: constants.RestApi, + OrganizationID: oaOrg, + Name: oaDisplayName, + Version: oaVersion, + ProjectID: oaProjectID, + Origin: origin, + } + e.apiRepo.byHandle[oaAPIHandle] = &model.APIMetadata{ + ID: oaAPIUUID, Handle: oaAPIHandle, Kind: constants.RestApi, OrganizationID: oaOrg, + } +} + +type oaFormField struct{ name, value string } + +func oaImportBody(fields []oaFormField, specFileName string, specContent []byte) ([]byte, string) { + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for _, f := range fields { + _ = w.WriteField(f.name, f.value) + } + if specContent != nil { + part, _ := w.CreateFormFile("file", specFileName) + _, _ = part.Write(specContent) + } + _ = w.Close() + return buf.Bytes(), w.FormDataContentType() +} + +func (e *oaEnv) do(t *testing.T, method, target, org, user string, body io.Reader, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, body) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if org != "" { + req = middleware.WithOrganization(req, org) + } + if user != "" { + req = middleware.WithUserID(req, user) + } + rec := httptest.NewRecorder() + e.mux.ServeHTTP(rec, req) + return rec +} + +func oaAssertError(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantCode string) { + t.Helper() + if rec.Code != wantStatus { + t.Fatalf("status = %d, want %d; body: %s", rec.Code, wantStatus, rec.Body.String()) + } + var body struct { + Status, Code string + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body: %v; body: %s", err, rec.Body.String()) + } + if body.Code != wantCode { + t.Fatalf("code = %q, want %q; body: %s", body.Code, wantCode, rec.Body.String()) + } + if body.Status != "error" { + t.Fatalf("status field = %q, want %q", body.Status, "error") + } +} + +// --------------------------------------------------------------------------- +// POST /rest-apis/import-openapi +// --------------------------------------------------------------------------- + +const importPath = constants.APIBasePath + "/rest-apis/import-openapi" + +func fullImportFields() []oaFormField { + return []oaFormField{ + {"displayName", oaDisplayName}, + {"version", oaVersion}, + {"context", oaContext}, + {"projectId", oaProjectHand}, + {"upstream", oaMainUpstream}, + } +} + +func TestImportOpenAPI_MissingOrganizationIsUnauthorized(t *testing.T) { + env := newOAEnv(t) + body, ct := oaImportBody(fullImportFields(), "spec.yaml", []byte(validOpenAPI)) + rec := env.do(t, http.MethodPost, importPath, "", "", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusUnauthorized, apperror.CodeCommonUnauthorized) +} + +func TestImportOpenAPI_NotMultipartIsBadRequest(t *testing.T) { + env := newOAEnv(t) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "", strings.NewReader("not multipart"), "application/json") + // Multipart parse fails; the handler maps that to a validation 400. + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestImportOpenAPI_RejectsInvalidRequests(t *testing.T) { + // Each case drops one required field or supplies an invalid value. Every + // branch is reached BEFORE the apiService touches the repository, so a + // successful rejection also proves the handler short-circuits early. + cases := []struct { + name string + fields []oaFormField + file []byte + }{ + {"missing displayName", []oaFormField{ + {"version", oaVersion}, {"context", oaContext}, {"projectId", oaProjectHand}, {"upstream", oaMainUpstream}, + }, []byte(validOpenAPI)}, + {"missing version", []oaFormField{ + {"displayName", oaDisplayName}, {"context", oaContext}, {"projectId", oaProjectHand}, {"upstream", oaMainUpstream}, + }, []byte(validOpenAPI)}, + {"missing context", []oaFormField{ + {"displayName", oaDisplayName}, {"version", oaVersion}, {"projectId", oaProjectHand}, {"upstream", oaMainUpstream}, + }, []byte(validOpenAPI)}, + {"missing projectId", []oaFormField{ + {"displayName", oaDisplayName}, {"version", oaVersion}, {"context", oaContext}, {"upstream", oaMainUpstream}, + }, []byte(validOpenAPI)}, + {"empty upstream", []oaFormField{ + {"displayName", oaDisplayName}, {"version", oaVersion}, {"context", oaContext}, {"projectId", oaProjectHand}, + }, []byte(validOpenAPI)}, + {"invalid upstream JSON", []oaFormField{ + {"displayName", oaDisplayName}, {"version", oaVersion}, {"context", oaContext}, {"projectId", oaProjectHand}, + {"upstream", "not-json"}, + }, []byte(validOpenAPI)}, + {"upstream main with both url and ref", []oaFormField{ + {"displayName", oaDisplayName}, {"version", oaVersion}, {"context", oaContext}, {"projectId", oaProjectHand}, + {"upstream", `{"main":{"url":"http://x:3000","ref":"shared-upstream"}}`}, + }, []byte(validOpenAPI)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newOAEnv(t) + body, ct := oaImportBody(tc.fields, "spec.yaml", tc.file) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + // The repo must not have been written to on an invalid request. + if env.apiRepo.createdID != "" { + t.Errorf("CreateAPI reached the repo on invalid request %q", tc.name) + } + }) + } +} + +func TestImportOpenAPI_NeitherFileNorURLIsBadRequest(t *testing.T) { + env := newOAEnv(t) + // Build a multipart body with the fields but NO file part and no url field. + body, ct := oaImportBody(fullImportFields(), "", nil) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestImportOpenAPI_BothFileAndURLIsBadRequest(t *testing.T) { + // Exactly-one-of enforcement at the multipart boundary — if a caller + // mistakenly sends both, the handler must refuse rather than pick a + // silent winner. + env := newOAEnv(t) + fields := append(fullImportFields(), oaFormField{"url", "http://example.invalid/spec.yaml"}) + body, ct := oaImportBody(fields, "spec.yaml", []byte(validOpenAPI)) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestImportOpenAPI_InvalidSpecIsBadRequest(t *testing.T) { + // The spec is read through apiDocumentService.ExtractOperationsFromSpec, + // which rejects anything that isn't a valid OpenAPI 3.x document. + env := newOAEnv(t) + body, ct := oaImportBody(fullImportFields(), "spec.yaml", []byte("this is not an openapi document")) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + if env.apiRepo.createdID != "" { + t.Errorf("CreateAPI reached the repo despite invalid spec") + } +} + +func TestImportOpenAPI_SuccessCreatesAPIAndStoresSpec(t *testing.T) { + env := newOAEnv(t) + body, ct := oaImportBody(fullImportFields(), "orders.yaml", []byte(validOpenAPI)) + + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String()) + } + // Location header points at the new API's handle. + wantLocation := constants.APIBasePath + "/rest-apis/" + oaAPIHandle + if got := rec.Header().Get("Location"); got != wantLocation { + t.Errorf("Location = %q, want %q", got, wantLocation) + } + // Response is the newly created API's metadata. + var created api.RESTAPI + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode created api: %v", err) + } + if created.DisplayName != oaDisplayName { + t.Errorf("displayName = %q, want %q", created.DisplayName, oaDisplayName) + } + // Operations were extracted from the spec: GET/POST /pets. + if created.Operations == nil || len(*created.Operations) < 2 { + t.Errorf("expected the two spec operations to be attached, got %+v", created.Operations) + } + // The spec was stored under the reserved DEFINITION handle/type for the + // new artifact, so the GET /openapi endpoint can later serve it back. + if len(env.docRepo.docs) != 1 { + t.Fatalf("docs stored = %d, want 1", len(env.docRepo.docs)) + } + if got := env.docRepo.docs[constants.DocumentHandleDefinition]; got == nil || + got.Type != constants.DocumentTypeDefinition { + t.Errorf("the spec was not stored under the DEFINITION handle/type: %+v", env.docRepo.docs) + } +} + +func TestImportOpenAPI_PersistFailureRollsBackAPICreate(t *testing.T) { + // When the document write fails after a successful API create, the + // handler must delete the just-created API so the user doesn't end up + // with an orphaned, specless row. + env := newOAEnv(t) + env.docRepo.upsertErr = errors.New("disk full") + // CreateDocument fallback goes through CreateDocument on the real service, + // which calls docRepo.CreateDocument. Simulate the write failing there. + env.docRepo.createErr = errors.New("disk full") + + body, ct := oaImportBody(fullImportFields(), "orders.yaml", []byte(validOpenAPI)) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + + // Rollback: the API that was briefly created is now gone. + if _, exists := env.apiRepo.byUUID[oaAPIUUID]; exists { + t.Errorf("API %q was not rolled back after spec persist failure", oaAPIUUID) + } +} + +func TestImportOpenAPI_ProjectNotFoundIsNotFound(t *testing.T) { + env := newOAEnv(t) + fields := []oaFormField{ + {"displayName", oaDisplayName}, + {"version", oaVersion}, + {"context", oaContext}, + {"projectId", "no-such-project"}, // Not in the mock repo. + {"upstream", oaMainUpstream}, + } + body, ct := oaImportBody(fields, "spec.yaml", []byte(validOpenAPI)) + rec := env.do(t, http.MethodPost, importPath, oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusNotFound, apperror.CodeProjectNotFound) + if env.apiRepo.createdID != "" { + t.Errorf("API was created despite missing project") + } +} + +// --------------------------------------------------------------------------- +// GET /rest-apis/{restApiId}/openapi +// --------------------------------------------------------------------------- + +func openapiPath(handle string) string { + return constants.APIBasePath + "/rest-apis/" + handle + "/openapi" +} + +func TestGetOpenAPISpec_MissingOrganizationIsUnauthorized(t *testing.T) { + env := newOAEnv(t) + rec := env.do(t, http.MethodGet, openapiPath(oaAPIHandle), "", "", nil, "") + oaAssertError(t, rec, http.StatusUnauthorized, apperror.CodeCommonUnauthorized) +} + +func TestGetOpenAPISpec_UnknownAPIIsNotFound(t *testing.T) { + env := newOAEnv(t) + rec := env.do(t, http.MethodGet, openapiPath("missing-api"), oaOrg, "", nil, "") + oaAssertError(t, rec, http.StatusNotFound, apperror.CodeRESTAPINotFound) +} + +func TestGetOpenAPISpec_StreamsStoredContent(t *testing.T) { + env := newOAEnv(t) + env.seedExistingAPI(false) + // Seed the DEFINITION doc under the artifact; the handler reads it back. + env.docRepo.docs[constants.DocumentHandleDefinition] = &model.Document{ + ArtifactUUID: oaAPIUUID, + OrganizationUUID: oaOrg, + Type: constants.DocumentTypeDefinition, + Handle: constants.DocumentHandleDefinition, + DisplayName: constants.DocumentDisplayNameDefinition, + FileName: "orders.yaml", + ContentType: "application/yaml", + Content: []byte(validOpenAPI), + } + + rec := env.do(t, http.MethodGet, openapiPath(oaAPIHandle), oaOrg, "", nil, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + // Response is {content: ""} — a JSON envelope, not raw bytes. + var resp api.OpenAPIContent + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body: %s", err, rec.Body.String()) + } + if resp.Content == nil || *resp.Content != validOpenAPI { + t.Errorf("content mismatch; got %v", resp.Content) + } +} + +func TestGetOpenAPISpec_NoSpecDocumentIsNotFound(t *testing.T) { + // API exists but no DEFINITION row was ever created (e.g. an API created + // via the plain POST /rest-apis path, not import-openapi). + env := newOAEnv(t) + env.seedExistingAPI(false) + rec := env.do(t, http.MethodGet, openapiPath(oaAPIHandle), oaOrg, "", nil, "") + oaAssertError(t, rec, http.StatusNotFound, apperror.CodeCommonNotFound) +} + +// --------------------------------------------------------------------------- +// PUT /rest-apis/{restApiId}/openapi +// --------------------------------------------------------------------------- + +func TestPutOpenAPISpec_MissingOrganizationIsUnauthorized(t *testing.T) { + env := newOAEnv(t) + body, ct := oaImportBody(nil, "spec.yaml", []byte(validOpenAPI)) + rec := env.do(t, http.MethodPut, openapiPath(oaAPIHandle), "", "", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusUnauthorized, apperror.CodeCommonUnauthorized) +} + +func TestPutOpenAPISpec_NotMultipartIsBadRequest(t *testing.T) { + env := newOAEnv(t) + env.seedExistingAPI(false) + rec := env.do(t, http.MethodPut, openapiPath(oaAPIHandle), oaOrg, "alice", + strings.NewReader(`openapi: 3.0.0`), "application/yaml") + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestPutOpenAPISpec_UnknownAPIIsNotFound(t *testing.T) { + env := newOAEnv(t) + body, ct := oaImportBody(nil, "spec.yaml", []byte(validOpenAPI)) + rec := env.do(t, http.MethodPut, openapiPath("missing-api"), oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusNotFound, apperror.CodeRESTAPINotFound) +} + +func TestPutOpenAPISpec_InvalidSpecIsBadRequest(t *testing.T) { + env := newOAEnv(t) + env.seedExistingAPI(false) + body, ct := oaImportBody(nil, "spec.yaml", []byte("not a spec")) + rec := env.do(t, http.MethodPut, openapiPath(oaAPIHandle), oaOrg, "alice", bytes.NewReader(body), ct) + oaAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + // The upsert path must never have been reached. + if len(env.docRepo.upserted) != 0 { + t.Errorf("UpsertDocument reached the repo despite invalid spec") + } +} + +func TestPutOpenAPISpec_UpdatesSpecAndSyncsOperations(t *testing.T) { + // A non-read-only API: the handler re-extracts operations from the new + // spec, merges them with the existing API's operation list, calls + // UpdateAPIByHandle, and finally upserts the spec document. + env := newOAEnv(t) + env.seedExistingAPI(false) + // Seed a prior spec so this is a true replacement, not a first write. + env.docRepo.docs[constants.DocumentHandleDefinition] = &model.Document{ + ArtifactUUID: oaAPIUUID, + OrganizationUUID: oaOrg, + Type: constants.DocumentTypeDefinition, + Handle: constants.DocumentHandleDefinition, + DisplayName: constants.DocumentDisplayNameDefinition, + FileName: "old.yaml", + Content: []byte(validOpenAPI), + } + body, ct := oaImportBody(nil, "new.yaml", []byte(updatedOpenAPI)) + + rec := env.do(t, http.MethodPut, openapiPath(oaAPIHandle), oaOrg, "alice", bytes.NewReader(body), ct) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + // Response carries the new spec body. + var resp api.OpenAPIContent + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Content == nil || *resp.Content != updatedOpenAPI { + t.Errorf("content mismatch after upsert") + } + // UpsertDocument was called with the new content. + if len(env.docRepo.upserted) != 1 { + t.Fatalf("upserts = %d, want 1", len(env.docRepo.upserted)) + } + if got := env.docRepo.upserted[0]; string(got.Content) != updatedOpenAPI { + t.Errorf("stored content differs from the upload") + } +} + +func TestPutOpenAPISpec_ReadOnlyAPIValidatesWithoutSyncingOperations(t *testing.T) { + // A read-only API comes from a gateway-originated import; its operations + // are the authoritative source and the control plane must not overwrite + // them. The handler validates the spec and still persists the document. + env := newOAEnv(t) + env.seedExistingAPI(true) + body, ct := oaImportBody(nil, "spec.yaml", []byte(updatedOpenAPI)) + + rec := env.do(t, http.MethodPut, openapiPath(oaAPIHandle), oaOrg, "alice", bytes.NewReader(body), ct) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + // The spec was stored, but UpdateAPI was NOT called (operations stay the + // gateway's). + if len(env.docRepo.upserted) != 1 { + t.Errorf("UpsertDocument was expected to run exactly once") + } +} diff --git a/platform-api/internal/handler/api_thumbnail.go b/platform-api/internal/handler/api_thumbnail.go new file mode 100644 index 0000000000..bb083ddcc0 --- /dev/null +++ b/platform-api/internal/handler/api_thumbnail.go @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package handler + +import ( + "errors" + "io" + "log/slog" + "net/http" + "strings" + + "github.com/wso2/api-platform/platform-api/config" + "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/dto" + "github.com/wso2/api-platform/platform-api/internal/middleware" + "github.com/wso2/api-platform/platform-api/internal/router" + "github.com/wso2/api-platform/platform-api/internal/service" +) + +// APIThumbnailHandler serves the /apis/{apiType}/{apiId}/thumbnail endpoints. +// Thumbnail is a singleton image document per artifact — one PUT replaces +// whatever was there (no POST), GET streams the stored bytes, and DELETE +// removes it so the UI falls back to the artifact's name-initials avatar. +type APIThumbnailHandler struct { + service *service.APIDocumentService + identity *service.IdentityService + slogger *slog.Logger + maxBodyBytes int64 +} + +func NewAPIThumbnailHandler(apiDocumentService *service.APIDocumentService, identity *service.IdentityService, slogger *slog.Logger, cfg *config.Server) *APIThumbnailHandler { + maxBytes := constants.DefaultThumbnailMaxBytes + if cfg != nil && cfg.ThumbnailMaxFetchBytes > 0 { + maxBytes = cfg.ThumbnailMaxFetchBytes + } + return &APIThumbnailHandler{ + service: apiDocumentService, + identity: identity, + slogger: slogger, + maxBodyBytes: maxBytes, + } +} + +func (h *APIThumbnailHandler) RegisterRoutes(mux router.Router) { + h.slogger.Debug("Registering API thumbnail routes") + base := constants.APIBasePath + "/apis/{apiType}/{apiId}/thumbnail" + mux.HandleFunc("GET "+base, middleware.MapErrors(h.slogger, h.GetThumbnail)) + mux.HandleFunc("PUT "+base, middleware.MapErrors(h.slogger, h.UpsertThumbnail)) + mux.HandleFunc("DELETE "+base, middleware.MapErrors(h.slogger, h.DeleteThumbnail)) +} + +func (h *APIThumbnailHandler) resolveArtifactUUID(r *http.Request) (orgID, artifactUUID string, err error) { + orgID, ok := middleware.GetOrganizationFromRequest(r) + if !ok { + return "", "", apperror.Unauthorized.New().WithLogMessage("organization claim not found in token") + } + apiID := r.PathValue("apiId") + if apiID == "" { + return "", "", apperror.ValidationFailed.New("API ID is required") + } + apiType := r.PathValue("apiType") + if apiType == "" { + return "", "", apperror.ValidationFailed.New("API type is required") + } + artifactUUID, err = h.service.ResolveArtifactUUID(apiType, apiID, orgID) + if err != nil { + return "", "", serviceError(err, "failed to resolve API "+apiID+" of type "+apiType) + } + return orgID, artifactUUID, nil +} + +// GetThumbnail handles GET /apis/{apiType}/{apiId}/thumbnail. Streams the stored bytes +// with the sniffed Content-Type. 204 No Content when thumbnail is not set +func (h *APIThumbnailHandler) GetThumbnail(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + + doc, content, err := h.service.GetDocumentWithContent(artifactUUID, constants.DocumentHandleThumbnail, + orgID, constants.DocumentTypeThumbnail) + if err != nil { + if apperror.NotFound.Is(err) { + w.WriteHeader(http.StatusNoContent) + return nil + } + return serviceError(err, "failed to fetch thumbnail") + } + if len(content) == 0 { + w.WriteHeader(http.StatusNoContent) + return nil + } + + ct := "application/octet-stream" + if doc.ContentType != nil && *doc.ContentType != "" { + ct = *doc.ContentType + } + w.Header().Set("Content-Type", ct) + w.Header().Set("X-Content-Type-Options", "nosniff") + // Thumbnail bytes change in place at a fixed URL — a browser HTTP cache + // would return stale bytes on the first refetch after a PUT. + // no-store keeps the browser from writing a cache entry at all. + w.Header().Set("Cache-Control", "no-store") + if doc.FileName != nil && *doc.FileName != "" { + fn := strings.NewReplacer(`"`, `\"`, `\`, `\\`).Replace(*doc.FileName) + w.Header().Set("Content-Disposition", `inline; filename="`+fn+`"`) + } + _, _ = w.Write(content) + return nil +} + +// UpsertThumbnail handles PUT /apis/{apiType}/{apiId}/thumbnail. +// The server sniffs the uploaded bytes and rejects anything that isn't JPEG or PNG; +// the uploader's declared Content-Type and filename extension are not trusted. +func (h *APIThumbnailHandler) UpsertThumbnail(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + if artifactUUID == "" { + return apperror.ValidationFailed.New("artifact UUID is required") + } + updatedBy, err := resolveActorErr(r, h.identity, "upsert thumbnail") + if err != nil { + return err + } + + const multipartOverhead = 1 << 20 + r.Body = http.MaxBytesReader(w, r.Body, h.maxBodyBytes+multipartOverhead) + if err := r.ParseMultipartForm(h.maxBodyBytes); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + return apperror.PayloadTooLarge.New("thumbnail body exceeds the maximum allowed size") + } + return apperror.ValidationFailed.New("invalid multipart form") + } + + file, header, fileErr := r.FormFile("file") + if fileErr != nil { + return apperror.ValidationFailed.New("`file` field is required") + } + defer file.Close() + + data, readErr := io.ReadAll(io.LimitReader(file, h.maxBodyBytes+1)) + if readErr != nil { + return apperror.ValidationFailed.New("failed to read uploaded file") + } + if int64(len(data)) > h.maxBodyBytes { + return apperror.PayloadTooLarge.New("thumbnail exceeds maximum allowed size") + } + if len(data) == 0 { + return apperror.ValidationFailed.New("thumbnail content is required") + } + sniffedContentType := h.service.GetImageContentType(data) + if !allowedThumbnailContentTypes[sniffedContentType] { + return apperror.ValidationFailed.New("thumbnail must be a JPEG or PNG image") + } + fileName := sanitizeUploadFileName(header.Filename) + + req := &dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + FileName: fileName, + Content: data, + } + + if err := h.service.UpsertDocument(req, orgID, updatedBy, artifactUUID); err != nil { + return serviceError(err, "failed to upsert thumbnail") + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// DeleteThumbnail handles DELETE /apis/{apiType}/{apiId}/thumbnail. +func (h *APIThumbnailHandler) DeleteThumbnail(w http.ResponseWriter, r *http.Request) error { + orgID, artifactUUID, err := h.resolveArtifactUUID(r) + if err != nil { + return err + } + deletedBy, err := resolveActorErr(r, h.identity, "delete thumbnail") + if err != nil { + return err + } + if err := h.service.DeleteAPIThumbnail(artifactUUID, orgID, deletedBy); err != nil { + return serviceError(err, "failed to delete thumbnail") + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +// allowedThumbnailContentTypes is the sniff-based allowlist for thumbnail +// uploads. The uploader's Content-Type header and filename are untrusted — +// http.DetectContentType reads magic bytes from the payload itself. +var allowedThumbnailContentTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, +} diff --git a/platform-api/internal/handler/api_thumbnail_test.go b/platform-api/internal/handler/api_thumbnail_test.go new file mode 100644 index 0000000000..bb4689a526 --- /dev/null +++ b/platform-api/internal/handler/api_thumbnail_test.go @@ -0,0 +1,418 @@ +/* + * 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. + * + */ + +// Unit tests for the /apis/{apiType}/{apiId}/thumbnail handlers. They reuse the +// in-memory fakes and request helpers from api_document_test.go. + +package handler + +import ( + "bytes" + "database/sql" + "errors" + "io" + "log/slog" + "net/http" + "testing" + + "github.com/wso2/api-platform/platform-api/config" + "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" +) + +// Minimal payloads that net/http.DetectContentType classifies by magic bytes. +var ( + thumbPNG = append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0}, 32)...) + thumbJPEG = append([]byte("\xff\xd8\xff\xe0"), bytes.Repeat([]byte{0}, 32)...) +) + +func thumbSeed(env *docHEnv, content []byte, contentType, fileName string) *model.Document { + return env.seed(&model.Document{ + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + FileName: fileName, + ContentType: contentType, + Content: content, + }) +} + +func thumbPut(t *testing.T, env *docHEnv, name string, content []byte) int { + t.Helper() + rec := env.form(t, http.MethodPut, docHThumbPath, nil, docHFile{field: "file", name: name, content: content}) + return rec.Code +} + +func TestNewAPIThumbnailHandler_MaxBodyBytes(t *testing.T) { + cases := []struct { + name string + cfg *config.Server + want int64 + }{ + {"nil config uses the default", nil, constants.DefaultThumbnailMaxBytes}, + {"zero value uses the default", &config.Server{}, constants.DefaultThumbnailMaxBytes}, + {"configured value wins", &config.Server{ThumbnailMaxFetchBytes: 2048}, 2048}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := NewAPIThumbnailHandler(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), tc.cfg) + if h.maxBodyBytes != tc.want { + t.Errorf("maxBodyBytes = %d, want %d", h.maxBodyBytes, tc.want) + } + }) + } +} + +func TestThumbnailRoutes_UnsupportedMethodsAreRejected(t *testing.T) { + env := newDocHEnv(t, nil) + for _, method := range []string{http.MethodPost, http.MethodPatch} { + if rec := env.get(t, method, docHThumbPath); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s status = %d, want %d", method, rec.Code, http.StatusMethodNotAllowed) + } + } +} + +func TestThumbnailHandlers_MissingOrganizationIsUnauthorized(t *testing.T) { + env := newDocHEnv(t, nil) + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + rec := env.do(t, method, docHThumbPath, "", "", nil, "") + docHAssertError(t, rec, http.StatusUnauthorized, apperror.CodeCommonUnauthorized) + }) + } +} + +func TestThumbnailHandlers_UnresolvableArtifactIsNotFound(t *testing.T) { + env := newDocHEnv(t, nil) + apiBase := constants.APIBasePath + "/apis/" + cases := []struct{ name, target, org string }{ + {"unknown kind", apiBase + "bogus-kind/" + docHAPI + "/thumbnail", docHOrg}, + {"unknown api", apiBase + docHKind + "/no-such-api/thumbnail", docHOrg}, + {"api in another organization", docHThumbPath, "org-2"}, + } + for _, tc := range cases { + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + t.Run(tc.name+" "+method, func(t *testing.T) { + rec := env.do(t, method, tc.target, tc.org, "", nil, "") + docHAssertError(t, rec, http.StatusNotFound, apperror.CodeCommonNotFound) + }) + } + } +} + +// --------------------------------------------------------------------------- +// GET /thumbnail +// --------------------------------------------------------------------------- + +func TestGetThumbnail_StreamsStoredImage(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, thumbPNG, "image/png", "logo.png") + + rec := env.get(t, http.MethodGet, docHThumbPath) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !bytes.Equal(rec.Body.Bytes(), thumbPNG) { + t.Errorf("body does not match the stored bytes") + } + wantHeaders := map[string]string{ + "Content-Type": "image/png", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", // bytes change in place at a fixed URL + "Content-Disposition": `inline; filename="logo.png"`, + } + for name, want := range wantHeaders { + if got := rec.Header().Get(name); got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } +} + +func TestGetThumbnail_HeaderFallbacksAndEscaping(t *testing.T) { + t.Run("missing content type falls back to octet-stream", func(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, thumbPNG, "", "logo.png") + rec := env.get(t, http.MethodGet, docHThumbPath) + if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" { + t.Errorf("Content-Type = %q, want application/octet-stream", got) + } + }) + t.Run("missing file name omits Content-Disposition", func(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, thumbPNG, "image/png", "") + rec := env.get(t, http.MethodGet, docHThumbPath) + if got := rec.Header().Get("Content-Disposition"); got != "" { + t.Errorf("Content-Disposition = %q, want none", got) + } + }) + t.Run("quotes and backslashes in the file name are escaped", func(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, thumbPNG, "image/png", `lo"go\1.png`) + rec := env.get(t, http.MethodGet, docHThumbPath) + want := `inline; filename="lo\"go\\1.png"` + if got := rec.Header().Get("Content-Disposition"); got != want { + t.Errorf("Content-Disposition = %q, want %q", got, want) + } + }) +} + +func TestGetThumbnail_NoContentWhenNoneIsSet(t *testing.T) { + t.Run("never uploaded", func(t *testing.T) { + env := newDocHEnv(t, nil) + if rec := env.get(t, http.MethodGet, docHThumbPath); rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", rec.Code) + } + }) + t.Run("stored row has no bytes", func(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, nil, "image/png", "logo.png") + if rec := env.get(t, http.MethodGet, docHThumbPath); rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", rec.Code) + } + }) +} + +// Only the thumbnail row is ever served here, never a user document that +// happens to be stored under the reserved handle. +func TestGetThumbnail_IgnoresRowsOfAnotherType(t *testing.T) { + env := newDocHEnv(t, nil) + env.seed(&model.Document{ + Type: constants.DocumentTypeDefinition, Handle: constants.DocumentHandleThumbnail, + Content: []byte("openapi: 3.0.0"), ContentType: "application/yaml", + }) + if rec := env.get(t, http.MethodGet, docHThumbPath); rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204 (no thumbnail row exists)", rec.Code) + } +} + +func TestGetThumbnail_RepositoryFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.getErr = errors.New("db down") + docHAssertError(t, env.get(t, http.MethodGet, docHThumbPath), + http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +// --------------------------------------------------------------------------- +// PUT /thumbnail +// --------------------------------------------------------------------------- + +func TestUpsertThumbnail_StoresSniffedImage(t *testing.T) { + cases := []struct { + name, fileName string + content []byte + wantType string + }{ + {"png", "logo.png", thumbPNG, "image/png"}, + {"jpeg", "photo.jpg", thumbJPEG, "image/jpeg"}, + // The declared extension is never trusted; the bytes decide. + {"jpeg bytes under a .png name", "misleading.png", thumbJPEG, "image/jpeg"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newDocHEnv(t, nil) + if code := thumbPut(t, env, tc.fileName, tc.content); code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", code) + } + if len(env.docs.upserted) != 1 { + t.Fatalf("upserted %d rows, want 1", len(env.docs.upserted)) + } + got := env.docs.upserted[0] + if got.Type != constants.DocumentTypeThumbnail || got.Handle != constants.DocumentHandleThumbnail { + t.Errorf("stored as type=%q handle=%q, want the thumbnail singleton", got.Type, got.Handle) + } + if got.DisplayName != constants.DocumentDisplayNameThumbnail { + t.Errorf("displayName = %q", got.DisplayName) + } + if got.ContentType != tc.wantType { + t.Errorf("contentType = %q, want %q", got.ContentType, tc.wantType) + } + if got.FileName != tc.fileName { + t.Errorf("fileName = %q, want %q", got.FileName, tc.fileName) + } + if !bytes.Equal(got.Content, tc.content) { + t.Errorf("stored bytes differ from the upload") + } + if got.ArtifactUUID != docHArtifact || got.OrganizationUUID != docHOrg { + t.Errorf("stored under artifact=%q org=%q", got.ArtifactUUID, got.OrganizationUUID) + } + if call := docHAuditLast(t, env.audit); call.action != "CREATE" || call.resourceType != "api_thumbnail" { + t.Errorf("audit = %+v, want CREATE api_thumbnail", call) + } + }) + } +} + +func TestUpsertThumbnail_ReplacesExistingAndIsServedBack(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, thumbPNG, "image/png", "old.png") + + if code := thumbPut(t, env, "new.jpg", thumbJPEG); code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", code) + } + if call := docHAuditLast(t, env.audit); call.action != "UPDATE" || call.resourceType != "api_thumbnail" { + t.Errorf("audit = %+v, want UPDATE api_thumbnail", call) + } + + rec := env.get(t, http.MethodGet, docHThumbPath) + if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), thumbJPEG) { + t.Fatalf("GET after PUT: status = %d, body mismatch = %v", rec.Code, !bytes.Equal(rec.Body.Bytes(), thumbJPEG)) + } + if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { + t.Errorf("Content-Type = %q, want image/jpeg", got) + } +} + +func TestUpsertThumbnail_RejectsInvalidUploads(t *testing.T) { + cases := []struct { + name, fileName string + content []byte + }{ + {"gif", "anim.gif", append([]byte("GIF89a"), make([]byte, 16)...)}, + {"plain text", "notes.png", []byte("not an image at all")}, + {"html masquerading as png", "xss.png", []byte("")}, + {"svg", "icon.svg", []byte(``)}, + {"empty file", "empty.png", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPut, docHThumbPath, nil, + docHFile{field: "file", name: tc.fileName, content: tc.content}) + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + if len(env.docs.upserted) != 0 { + t.Errorf("an invalid upload was stored") + } + }) + } +} + +func TestUpsertThumbnail_RequiresFileField(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.form(t, http.MethodPut, docHThumbPath, map[string]string{"note": "no file here"}) + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) + + // A file under the wrong field name is the same as no file. + rec = env.form(t, http.MethodPut, docHThumbPath, nil, docHFile{field: "image", name: "logo.png", content: thumbPNG}) + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestUpsertThumbnail_NotMultipartIsBadRequest(t *testing.T) { + env := newDocHEnv(t, nil) + rec := env.do(t, http.MethodPut, docHThumbPath, docHOrg, "", bytes.NewReader(thumbPNG), "image/png") + docHAssertError(t, rec, http.StatusBadRequest, apperror.CodeCommonValidationFailed) +} + +func TestUpsertThumbnail_OversizedFileIsPayloadTooLarge(t *testing.T) { + env := newDocHEnv(t, &config.Server{ThumbnailMaxFetchBytes: 16}) + // A valid PNG header, but larger than the configured ceiling. + if code := thumbPut(t, env, "big.png", thumbPNG); code != http.StatusRequestEntityTooLarge { + t.Errorf("status = %d, want 413", code) + } + if len(env.docs.upserted) != 0 { + t.Errorf("an oversized upload was stored") + } +} + +func TestUpsertThumbnail_OversizedBodyIsPayloadTooLarge(t *testing.T) { + env := newDocHEnv(t, &config.Server{ThumbnailMaxFetchBytes: 16}) + big := append(append([]byte{}, thumbPNG...), bytes.Repeat([]byte{0}, (1<<20)+1024)...) + rec := env.form(t, http.MethodPut, docHThumbPath, nil, docHFile{field: "file", name: "huge.png", content: big}) + docHAssertError(t, rec, http.StatusRequestEntityTooLarge, apperror.CodeCommonPayloadTooLarge) +} + +func TestUpsertThumbnail_RepositoryFailureIsInternalError(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.upsertErr = errors.New("disk full") + rec := env.form(t, http.MethodPut, docHThumbPath, nil, docHFile{field: "file", name: "logo.png", content: thumbPNG}) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) +} + +func TestThumbnailWrites_UnresolvableActorIsInternalError(t *testing.T) { + env := newDocHEnvWithIdentity(t, nil, docHFailingIdentityRepo{}) + thumbSeed(env, thumbPNG, "image/png", "logo.png") + + body, ct := docHForm(t, nil, docHFile{field: "file", name: "logo.png", content: thumbPNG}) + rec := env.do(t, http.MethodPut, docHThumbPath, docHOrg, "alice", body, ct) + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + + rec = env.do(t, http.MethodDelete, docHThumbPath, docHOrg, "alice", nil, "") + docHAssertError(t, rec, http.StatusInternalServerError, apperror.CodeCommonInternalError) + + if len(env.docs.upserted)+len(env.docs.deletedHandles) != 0 { + t.Errorf("a write reached the repository despite the identity failure") + } +} + +// --------------------------------------------------------------------------- +// DELETE /thumbnail +// --------------------------------------------------------------------------- + +func TestDeleteThumbnail_Success(t *testing.T) { + env := newDocHEnv(t, nil) + thumbSeed(env, thumbPNG, "image/png", "logo.png") + + rec := env.get(t, http.MethodDelete, docHThumbPath) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String()) + } + if len(env.docs.deletedHandles) != 1 || + env.docs.deletedHandles[0] != constants.DocumentHandleThumbnail || + env.docs.deletedTypes[0] != constants.DocumentTypeThumbnail { + t.Errorf("deleted handles=%v types=%v, want the thumbnail singleton", env.docs.deletedHandles, env.docs.deletedTypes) + } + if call := docHAuditLast(t, env.audit); call.action != "DELETE" || call.resourceType != "api_thumbnail" { + t.Errorf("audit = %+v, want DELETE api_thumbnail", call) + } + // Once removed, GET falls back to 204 so the UI shows the initials avatar. + if rec := env.get(t, http.MethodGet, docHThumbPath); rec.Code != http.StatusNoContent { + t.Errorf("GET after DELETE status = %d, want 204", rec.Code) + } +} + +func TestDeleteThumbnail_Failures(t *testing.T) { + t.Run("nothing to delete", func(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.deleteErr = sql.ErrNoRows + docHAssertError(t, env.get(t, http.MethodDelete, docHThumbPath), + http.StatusNotFound, apperror.CodeCommonNotFound) + }) + t.Run("repository failure", func(t *testing.T) { + env := newDocHEnv(t, nil) + env.docs.deleteErr = errors.New("db down") + docHAssertError(t, env.get(t, http.MethodDelete, docHThumbPath), + http.StatusInternalServerError, apperror.CodeCommonInternalError) + }) +} + +// Guard against the sniffing allowlist drifting: only PNG and JPEG are accepted. +func TestAllowedThumbnailContentTypes(t *testing.T) { + want := map[string]bool{"image/png": true, "image/jpeg": true} + if len(allowedThumbnailContentTypes) != len(want) { + t.Errorf("allowlist = %v, want exactly %v", allowedThumbnailContentTypes, want) + } + for ct := range want { + if !allowedThumbnailContentTypes[ct] { + t.Errorf("%s missing from the allowlist", ct) + } + } + for _, ct := range []string{"image/gif", "image/svg+xml", "image/webp", "text/html; charset=utf-8", ""} { + if allowedThumbnailContentTypes[ct] { + t.Errorf("%q must not be accepted as a thumbnail type", ct) + } + } +} diff --git a/platform-api/internal/model/api_document.go b/platform-api/internal/model/api_document.go index 0f09386c5e..2b8c5210b9 100644 --- a/platform-api/internal/model/api_document.go +++ b/platform-api/internal/model/api_document.go @@ -17,17 +17,21 @@ package model +import "time" + // Document represents a stored document attached to an artifact (e.g. an OpenAPI spec). type Document struct { - ID string `json:"id" db:"uuid"` - ArtifactUUID string `json:"artifactId" db:"artifact_uuid"` - OrganizationUUID string `json:"organizationId" db:"organization_uuid"` - Type string `json:"type" db:"type"` - Handle string `json:"handle" db:"handle"` - DisplayName string `json:"displayName" db:"display_name"` - FileName string `json:"fileName" db:"file_name"` - ContentType string `json:"contentType" db:"content_type"` - Content []byte `json:"content,omitempty" db:"content"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + ID string `json:"id" db:"uuid"` + ArtifactUUID string `json:"artifactId" db:"artifact_uuid"` + OrganizationUUID string `json:"organizationId" db:"organization_uuid"` + Type string `json:"type" db:"type"` + Handle string `json:"handle" db:"handle"` + DisplayName string `json:"displayName" db:"display_name"` + FileName string `json:"fileName" db:"file_name"` + ContentType string `json:"contentType" db:"content_type"` + Content []byte `json:"content,omitempty" db:"content"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` } diff --git a/platform-api/internal/repository/api_document.go b/platform-api/internal/repository/api_document.go index 6aa4ce767e..1c3048d9b9 100644 --- a/platform-api/internal/repository/api_document.go +++ b/platform-api/internal/repository/api_document.go @@ -21,10 +21,12 @@ import ( "database/sql" "errors" "fmt" + "sort" "strings" "time" "github.com/google/uuid" + "github.com/wso2/api-platform/platform-api/internal/constants" "github.com/wso2/api-platform/platform-api/internal/database" "github.com/wso2/api-platform/platform-api/internal/model" ) @@ -62,66 +64,145 @@ func (r *DocumentRepo) CreateDocument(doc *model.Document) error { return nil } -// GetDocumentByArtifactAndHandle retrieves a single document by artifact UUID and handle. -func (r *DocumentRepo) GetDocumentByArtifactAndHandle(artifactUUID, handle, orgUUID string) (*model.Document, error) { +// GetDocument retrieves a single document by (artifactUUID, handle, orgUUID) +// Returns (nil, nil) when no matching row exists. +func (r *DocumentRepo) GetDocument(artifactUUID, handle, orgUUID, docType string) (*model.Document, error) { + whereClause := `WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ?` + args := []interface{}{artifactUUID, handle, orgUUID} + // docType is sent when the caller wants a strict match on type (reserved-type lookups) + if docType != "" { + whereClause += ` AND type = ?` + args = append(args, docType) + // docType is empty when the request came from the user-facing /docs/{docId} path, so exclude reserved types + } else if len(constants.ReservedAPIDocumentTypes) > 0 { + placeholders := make([]string, len(constants.ReservedAPIDocumentTypes)) + for i, t := range constants.ReservedAPIDocumentTypes { + placeholders[i] = "?" + args = append(args, t) + } + whereClause += ` AND type NOT IN (` + strings.Join(placeholders, ", ") + `)` + } + query := r.db.Rebind(` SELECT uuid, artifact_uuid, organization_uuid, type, handle, display_name, COALESCE(file_name, ''), COALESCE(content_type, ''), content, - COALESCE(created_by, ''), COALESCE(updated_by, '') + COALESCE(created_by, ''), created_at, + COALESCE(updated_by, ''), updated_at FROM api_documents - WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ? - `) - row := r.db.QueryRow(query, artifactUUID, handle, orgUUID) + ` + whereClause) + row := r.db.QueryRow(query, args...) doc := &model.Document{} if err := row.Scan( &doc.ID, &doc.ArtifactUUID, &doc.OrganizationUUID, &doc.Type, &doc.Handle, &doc.DisplayName, &doc.FileName, &doc.ContentType, &doc.Content, - &doc.CreatedBy, &doc.UpdatedBy, + &doc.CreatedBy, &doc.CreatedAt, + &doc.UpdatedBy, &doc.UpdatedAt, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("failed to get document by artifact and handle: %w", err) + return nil, fmt.Errorf("failed to get document: %w", err) } return doc, nil } -// GetDocumentByArtifactAndType retrieves the single document of a given type for an artifact. -// Returns nil (no error) when no matching row exists. -func (r *DocumentRepo) GetDocumentByArtifactAndType(artifactUUID, docType, orgUUID string) (*model.Document, error) { - query := r.db.Rebind(` +// ListDocumentsByArtifact returns user-facing documents for an artifact, +// optionally filtered by type, as metadata-only rows (no content column). +// +// Reserved types (constants.ReservedAPIDocumentTypes — DEFINITION, THUMBNAIL) +// are excluded at the SQL layer so the returned `total` reflects the +// user-visible row count rather than every row in the table, and pagination +// stays correct even on artifacts with many reserved rows. +func (r *DocumentRepo) ListDocumentsByArtifact(artifactUUID, orgUUID, docType string, limit, offset int) ([]*model.Document, int, error) { + whereClause := `WHERE artifact_uuid = ? AND organization_uuid = ?` + args := []interface{}{artifactUUID, orgUUID} + if docType != "" { + // get all docs whose type is not a fixed type (HowTo, Samples, …) when docType=Other + if docType == constants.DocumentTypeOther { + fixedTypes := constants.FixedAPIDocumentStoredTypes + placeholders := make([]string, len(fixedTypes)) + for i, t := range fixedTypes { + placeholders[i] = "?" + args = append(args, t) + } + whereClause += ` AND type NOT IN (` + strings.Join(placeholders, ", ") + `)` + } else { + whereClause += ` AND type = ?` + args = append(args, constants.DocumentTypePrefix + docType) + } + } + // exclude reserved types (DEFINITION, THUMBNAIL) from listing because they have dedicated endpoints + if len(constants.ReservedAPIDocumentTypes) > 0 { + placeholders := make([]string, len(constants.ReservedAPIDocumentTypes)) + for i, t := range constants.ReservedAPIDocumentTypes { + placeholders[i] = "?" + args = append(args, t) + } + whereClause += ` AND type NOT IN (` + strings.Join(placeholders, ", ") + `)` + } + + countQuery := r.db.Rebind(`SELECT COUNT(*) FROM api_documents ` + whereClause) + var total int + if err := r.db.QueryRow(countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count documents for artifact: %w", err) + } + if total == 0 { + return nil, 0, nil + } + + pageClause, pageArgs := r.db.PaginationClause(limit, offset) + listQuery := r.db.Rebind(` SELECT uuid, artifact_uuid, organization_uuid, type, handle, display_name, - COALESCE(file_name, ''), COALESCE(content_type, ''), content, - COALESCE(created_by, ''), COALESCE(updated_by, '') + COALESCE(file_name, ''), COALESCE(content_type, ''), + COALESCE(created_by, ''), created_at, + COALESCE(updated_by, ''), updated_at FROM api_documents - WHERE artifact_uuid = ? AND type = ? AND organization_uuid = ? - `) - row := r.db.QueryRow(query, artifactUUID, docType, orgUUID) - doc := &model.Document{} - if err := row.Scan( - &doc.ID, &doc.ArtifactUUID, &doc.OrganizationUUID, &doc.Type, - &doc.Handle, &doc.DisplayName, &doc.FileName, &doc.ContentType, &doc.Content, - &doc.CreatedBy, &doc.UpdatedBy, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil + ` + whereClause + ` + ORDER BY updated_at DESC, uuid DESC + ` + pageClause) + listArgs := append(append([]interface{}{}, args...), pageArgs...) + rows, err := r.db.Query(listQuery, listArgs...) + if err != nil { + return nil, 0, fmt.Errorf("failed to list documents for artifact: %w", err) + } + defer rows.Close() + + docs := make([]*model.Document, 0) + for rows.Next() { + doc := &model.Document{} + if err := rows.Scan( + &doc.ID, &doc.ArtifactUUID, &doc.OrganizationUUID, &doc.Type, + &doc.Handle, &doc.DisplayName, &doc.FileName, &doc.ContentType, + &doc.CreatedBy, &doc.CreatedAt, + &doc.UpdatedBy, &doc.UpdatedAt, + ); err != nil { + return nil, 0, fmt.Errorf("failed to scan document row: %w", err) } - return nil, fmt.Errorf("failed to get document by artifact and type: %w", err) + docs = append(docs, doc) } - return doc, nil + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("failed to iterate document rows: %w", err) + } + return docs, total, nil } -// UpsertDocument inserts or updates a document for the given (artifact_uuid, handle) pair. +// UpsertDocument writes the singleton OpenAPI definition for an artifact, +// scoped by (artifact_uuid, handle, type). The matching POST +// /rest-apis/{id}/openapi endpoint was deliberately removed, so the initial +// spec upload lands here too — hence the create-if-missing branch. The SET +// list is intentionally narrow (content + file_name + content_type): the +// type (DEFINITION) and display_name are fixed by convention for this +// singleton and must not be mutated through this path. func (r *DocumentRepo) UpsertDocument(doc *model.Document) error { now := time.Now().UTC() updateQuery := r.db.Rebind(` UPDATE api_documents SET file_name = ?, content_type = ?, content = ?, updated_by = ?, updated_at = ? - WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ? + WHERE artifact_uuid = ? AND handle = ? AND type = ? AND organization_uuid = ? `) result, err := r.db.Exec(updateQuery, doc.FileName, doc.ContentType, doc.Content, doc.UpdatedBy, now, - doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, + doc.ArtifactUUID, doc.Handle, doc.Type, doc.OrganizationUUID, ) if err != nil { return fmt.Errorf("failed to upsert document (update): %w", err) @@ -138,12 +219,19 @@ func (r *DocumentRepo) UpsertDocument(doc *model.Document) error { if err := r.CreateDocument(doc); err != nil { if IsUniqueViolation(err) { // A concurrent writer inserted between our UPDATE and INSERT; retry the UPDATE. - _, err = r.db.Exec(updateQuery, + retryResult, retryErr := r.db.Exec(updateQuery, doc.FileName, doc.ContentType, doc.Content, doc.UpdatedBy, now, - doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, + doc.ArtifactUUID, doc.Handle, doc.Type, doc.OrganizationUUID, ) - if err != nil { - return fmt.Errorf("failed to upsert document (retry update): %w", err) + if retryErr != nil { + return fmt.Errorf("failed to upsert document (retry update): %w", retryErr) + } + retryRows, retryErr := retryResult.RowsAffected() + if retryErr != nil { + return fmt.Errorf("failed to upsert document (retry rows affected): %w", retryErr) + } + if retryRows == 0 { + return fmt.Errorf("upsert document retry affected 0 rows — concurrent delete during insert/update race") } return nil } @@ -152,13 +240,115 @@ func (r *DocumentRepo) UpsertDocument(doc *model.Document) error { return nil } -// DeleteDocument removes a document by artifact UUID, handle, and org. -func (r *DocumentRepo) DeleteDocument(artifactUUID, handle, orgUUID string) error { - query := r.db.Rebind(`DELETE FROM api_documents WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ?`) - _, err := r.db.Exec(query, artifactUUID, handle, orgUUID) +// UpdateApiDocument applies metadata and/or content changes to a user-authored +// API document identified by (artifact_uuid, handle, org). Used by the +// user-facing PUT /apis/{apiType}/{apiId}/docs/{docId} path to update either +// the content or the metadata of an existing user doc. +// +// Reserved types (DEFINITION, THUMBNAIL) are excluded from the WHERE clause so +// this method can never mutate a system-managed row — reserved rows have their +// own write path (UpsertDocument). +func (r *DocumentRepo) UpdateApiDocument(doc *model.Document, updateContent bool) error { + now := time.Now().UTC() + + reservedPlaceholders := make([]string, len(constants.ReservedAPIDocumentTypes)) + for i := range constants.ReservedAPIDocumentTypes { + reservedPlaceholders[i] = "?" + } + notReserved := ` AND type NOT IN (` + strings.Join(reservedPlaceholders, ", ") + `)` + + var ( + query string + result sql.Result + err error + ) + if updateContent { + query = r.db.Rebind(` + UPDATE api_documents + SET type = ?, display_name = ?, file_name = ?, content_type = ?, content = ?, + updated_by = ?, updated_at = ? + WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ?` + notReserved) + args := []interface{}{ + doc.Type, doc.DisplayName, doc.FileName, doc.ContentType, doc.Content, + doc.UpdatedBy, now, + doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, + } + for _, t := range constants.ReservedAPIDocumentTypes { + args = append(args, t) + } + result, err = r.db.Exec(query, args...) + } else { + query = r.db.Rebind(` + UPDATE api_documents + SET type = ?, display_name = ?, file_name = ?, + updated_by = ?, updated_at = ? + WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ?` + notReserved) + args := []interface{}{ + doc.Type, doc.DisplayName, doc.FileName, + doc.UpdatedBy, now, + doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, + } + for _, t := range constants.ReservedAPIDocumentTypes { + args = append(args, t) + } + result, err = r.db.Exec(query, args...) + } + if err != nil { + return fmt.Errorf("failed to update document fields: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to read update affected rows: %w", err) + } + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +// DeleteDocument removes any type of document. +func (r *DocumentRepo) DeleteDocument(artifactUUID, handle, orgUUID, docType string) error { + query := r.db.Rebind(`DELETE FROM api_documents + WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ? AND type = ?`) + result, err := r.db.Exec(query, artifactUUID, handle, orgUUID, docType) + if err != nil { + return fmt.Errorf("failed to delete document: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to read delete affected rows: %w", err) + } + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +// DeleteApiDocument removes a document by artifact UUID, handle, and org. +// Reserved types (DEFINITION, THUMBNAIL) are excluded from the WHERE clause so +// this method can never delete a system-managed row +func (r *DocumentRepo) DeleteApiDocument(artifactUUID, handle, orgUUID string) error { + reservedPlaceholders := make([]string, len(constants.ReservedAPIDocumentTypes)) + for i := range constants.ReservedAPIDocumentTypes { + reservedPlaceholders[i] = "?" + } + query := r.db.Rebind(`DELETE FROM api_documents WHERE artifact_uuid = ? AND handle = ? AND organization_uuid = ? + AND type NOT IN (` + strings.Join(reservedPlaceholders, ", ") + `)`) + args := []interface{}{artifactUUID, handle, orgUUID} + for _, t := range constants.ReservedAPIDocumentTypes { + args = append(args, t) + } + result, err := r.db.Exec(query, args...) if err != nil { return fmt.Errorf("failed to delete document: %w", err) } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to read delete affected rows: %w", err) + } + if rows == 0 { + return sql.ErrNoRows + } return nil } @@ -180,6 +370,34 @@ func (r *DocumentRepo) DocumentHandleExistsForArtifact(artifactUUID, handle stri return true, nil } +// DocumentDisplayNameExistsForArtifact returns true if any user-authored document +// attached to the artifact already uses displayName +func (r *DocumentRepo) DocumentDisplayNameExistsForArtifact(artifactUUID, displayName, excludeHandle string) (bool, error) { + args := []interface{}{artifactUUID, displayName} + for _, t := range constants.ReservedAPIDocumentTypes { + args = append(args, t) + } + reservedPlaceholders := make([]string, len(constants.ReservedAPIDocumentTypes)) + for i := range constants.ReservedAPIDocumentTypes { + reservedPlaceholders[i] = "?" + } + q := `SELECT 1 FROM api_documents WHERE artifact_uuid = ? AND display_name = ? + AND type NOT IN (` + strings.Join(reservedPlaceholders, ", ") + `)` + if excludeHandle != "" { + q += ` AND handle != ?` + args = append(args, excludeHandle) + } + row := r.db.QueryRow(r.db.Rebind(q), args...) + var exists int + if err := row.Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("failed to check document display name for the artifact: %w", err) + } + return true, nil +} + // GetDocumentUUIDsByHandles resolves each handle to its document uuid, // scoped to one artifact — api_documents' real unique index is // (artifact_uuid, handle), so a handle is only guaranteed unique per @@ -252,3 +470,12 @@ func (r *DocumentRepo) GetDocumentHandlesByUUIDs(docUUIDs []string, orgUUID stri } return m, rows.Err() } + +func sortedMapKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} \ No newline at end of file diff --git a/platform-api/internal/repository/api_document_test.go b/platform-api/internal/repository/api_document_test.go new file mode 100644 index 0000000000..826dd08159 --- /dev/null +++ b/platform-api/internal/repository/api_document_test.go @@ -0,0 +1,1007 @@ +/* + * 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 repository + +import ( + "database/sql" + "errors" + "testing" + + "github.com/wso2/api-platform/platform-api/internal/constants" + "github.com/wso2/api-platform/platform-api/internal/database" + "github.com/wso2/api-platform/platform-api/internal/model" + + _ "github.com/mattn/go-sqlite3" +) + +// seedTestArtifact creates org + project + artifact rows so a document row +// can satisfy the api_documents.artifact_uuid foreign key. Returns the +// artifact UUID the test should attach documents to. +func seedTestArtifact(t *testing.T, db *database.DB, orgUUID, artifactUUID string) { + t.Helper() + + if _, err := db.Exec( + `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid, created_at, updated_at) + VALUES (?, ?, ?, 'default', 'idp-ref', datetime('now'), datetime('now'))`, + orgUUID, "test-org-"+orgUUID, "Test Org", + ); err != nil { + t.Fatalf("seed organization: %v", err) + } + + if _, err := db.Exec( + `INSERT INTO projects (uuid, handle, display_name, organization_uuid, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))`, + "project-"+artifactUUID, "test-project-"+artifactUUID, "Test Project", orgUUID, + ); err != nil { + t.Fatalf("seed project: %v", err) + } + + if _, err := db.Exec( + `INSERT INTO artifacts (uuid, type, organization_uuid) + VALUES (?, ?, ?)`, + artifactUUID, constants.RestApi, orgUUID, + ); err != nil { + t.Fatalf("seed artifact: %v", err) + } +} + +// seedExtraArtifact adds a project + artifact row under an organization that +// was already seeded by a prior seedTestArtifact call. Use when a test needs +// a second artifact in the SAME org — a second seedTestArtifact call would +// try to re-insert the org row and fail the handle unique index. +func seedExtraArtifact(t *testing.T, db *database.DB, orgUUID, artifactUUID string) { + t.Helper() + + if _, err := db.Exec( + `INSERT INTO projects (uuid, handle, display_name, organization_uuid, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))`, + "project-"+artifactUUID, "test-project-"+artifactUUID, "Test Project", orgUUID, + ); err != nil { + t.Fatalf("seed project: %v", err) + } + + if _, err := db.Exec( + `INSERT INTO artifacts (uuid, type, organization_uuid) + VALUES (?, ?, ?)`, + artifactUUID, constants.RestApi, orgUUID, + ); err != nil { + t.Fatalf("seed artifact: %v", err) + } +} + +// insertDocumentRow writes a document row directly without going through the +// repo — lets a test seed a THUMBNAIL/DEFINITION row to assert the repo's +// reserved-type guards without first testing the write path those guards cover. +func insertDocumentRow(t *testing.T, db *database.DB, doc *model.Document) { + t.Helper() + // api_documents.content is NOT NULL — default to an empty-but-non-nil blob + // so metadata-only seeds don't have to supply filler bytes. + content := doc.Content + if content == nil { + content = []byte{} + } + if _, err := db.Exec( + `INSERT INTO api_documents (uuid, artifact_uuid, organization_uuid, type, handle, display_name, file_name, content_type, content, created_by, created_at, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, datetime('now'))`, + doc.ID, doc.ArtifactUUID, doc.OrganizationUUID, doc.Type, doc.Handle, doc.DisplayName, + doc.FileName, doc.ContentType, content, doc.CreatedBy, doc.UpdatedBy, + ); err != nil { + t.Fatalf("insert document row: %v", err) + } +} + +// ListDocumentsByArtifact EXCLUDE reserved types (THUMBNAIL, DEFINITION) +func TestDocumentRepo_ListDocumentsByArtifact_ExcludesReservedTypes(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-list-reserved" + const artifactUUID = "artifact-list-reserved" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + // Seed one of each reserved type + one legitimate user doc. + insertDocumentRow(t, db, &model.Document{ + ID: "doc_thumbnail", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, Content: []byte{0x89, 'P'}, + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc_definition", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeDefinition, Handle: constants.DocumentHandleDefinition, + DisplayName: constants.DocumentDisplayNameDefinition, Content: []byte("{}"), + }) + insertDocumentRow(t, db, &model.Document{ + ID: "user-howto", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", + DisplayName: "Overview", Content: []byte("# Overview"), + }) + + repo := NewDocumentRepo(db) + + docs, total, err := repo.ListDocumentsByArtifact(artifactUUID, orgUUID, "", 10, 0) + if err != nil { + t.Fatalf("ListDocumentsByArtifact: %v", err) + } + if total != 1 || len(docs) != 1 { + t.Fatalf("expected only the HOW_TO row (1 total), got total=%d docs=%d", total, len(docs)) + } + if docs[0].Handle != "overview" { + t.Errorf("expected the HOW_TO row to be returned, got handle=%q (type=%q)", docs[0].Handle, docs[0].Type) + } +} + +// CreateDocument enforces UNIQUE (artifact_uuid, handle) via the DB index +func TestDocumentRepo_CreateDocument_UniqueHandlePerArtifact(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-create-unique" + const artifactUUID = "artifact-create-unique" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + + first := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", DisplayName: "Overview", + Content: []byte("# first"), CreatedBy: "alice", UpdatedBy: "alice", + } + if err := repo.CreateDocument(first); err != nil { + t.Fatalf("first CreateDocument: %v", err) + } + + dup := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", DisplayName: "Overview second", + Content: []byte("# dup"), CreatedBy: "bob", UpdatedBy: "bob", + } + err := repo.CreateDocument(dup) + if err == nil { + t.Fatal("second CreateDocument with duplicate (artifact, handle) succeeded — unique index not enforced") + } + // The repo exposes an IsUniqueViolation helper used by UpsertDocument; the + // service relies on it being correctly classified rather than a generic + // error. + if !IsUniqueViolation(err) { + t.Errorf("second CreateDocument err = %v, want IsUniqueViolation(err) == true", err) + } +} + +func TestDocumentRepo_DocumentHandleExistsForArtifact_ScopesToArtifact(t *testing.T) { + // The real UNIQUE index is (artifact_uuid, handle), not (org, handle) — + // two different artifacts in the same org can both have a doc called + // "overview". The exists-check must therefore be scoped to the artifact, + // or create flows would reject legitimate new docs. + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-handle-exists" + seedTestArtifact(t, db, orgUUID, "artifact-A") + seedTestArtifact(t, db, orgUUID+"-x", "artifact-B") // separate org so FK on artifact FK target is distinct + + insertDocumentRow(t, db, &model.Document{ + ID: "docA", ArtifactUUID: "artifact-A", OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", + DisplayName: "Overview on A", + }) + + repo := NewDocumentRepo(db) + + exists, err := repo.DocumentHandleExistsForArtifact("artifact-A", "overview") + if err != nil { + t.Fatalf("DocumentHandleExistsForArtifact: %v", err) + } + if !exists { + t.Fatal("expected handle to exist on artifact-A") + } + + exists, err = repo.DocumentHandleExistsForArtifact("artifact-B", "overview") + if err != nil { + t.Fatalf("DocumentHandleExistsForArtifact (other artifact): %v", err) + } + if exists { + t.Fatal("exists-check leaked across artifacts — second artifact should be free to use the same handle") + } +} + +// DeleteApiDocument removes a how_to doc but not definition or thumbnail type docs. +func TestDocumentRepo_DeleteApiDocument_RemovesUserDocNotReserved(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-del-user" + const artifactUUID = "artifact-del-user" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-user", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", DisplayName: "Overview", + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-def", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeDefinition, + Handle: constants.DocumentHandleDefinition, + DisplayName: constants.DocumentDisplayNameDefinition, + }) + + repo := NewDocumentRepo(db) + + if err := repo.DeleteApiDocument(artifactUUID, "overview", orgUUID); err != nil { + t.Fatalf("DeleteApiDocument(user doc): %v", err) + } + + for _, handle := range []string{constants.DocumentHandleThumbnail, constants.DocumentHandleDefinition} { + err := repo.DeleteApiDocument(artifactUUID, handle, orgUUID) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("DeleteApiDocument(reserved handle %q) = %v, want sql.ErrNoRows", handle, err) + } + } + + var survivors int + if err := db.QueryRow(`SELECT COUNT(*) FROM api_documents WHERE artifact_uuid = ?`, artifactUUID).Scan(&survivors); err != nil { + t.Fatalf("count survivors: %v", err) + } + if survivors != 2 { + t.Errorf("expected 2 reserved document rows to survive, found %d", survivors) + } +} + +// DeleteDocument removes definition or thumbnail type docs — the strict +// (handle, type) match is the opt-in path used by /openapi + /thumbnail. +func TestDocumentRepo_DeleteDocument_RemovesReservedDoc(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-del-reserved" + const artifactUUID = "artifact-del-reserved" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-def", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeDefinition, + Handle: constants.DocumentHandleDefinition, + DisplayName: constants.DocumentDisplayNameDefinition, + }) + + repo := NewDocumentRepo(db) + + if err := repo.DeleteDocument(artifactUUID, constants.DocumentHandleThumbnail, orgUUID, constants.DocumentTypeThumbnail); err != nil { + t.Fatalf("DeleteDocument(thumbnail): %v", err) + } + if err := repo.DeleteDocument(artifactUUID, constants.DocumentHandleDefinition, orgUUID, constants.DocumentTypeDefinition); err != nil { + t.Fatalf("DeleteDocument(definition): %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM api_documents WHERE artifact_uuid = ?`, artifactUUID).Scan(&count); err != nil { + t.Fatalf("count: %v", err) + } + if count != 0 { + t.Errorf("expected all reserved rows removed, found %d", count) + } +} + +// DeleteApiDocument throws sql.ErrNoRows if the row doesn't exist +func TestDocumentRepo_DeleteApiDocument_ReturnsErrNoRowsWhenMissing(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-del-user-missing" + const artifactUUID = "artifact-del-user-missing" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + err := repo.DeleteApiDocument(artifactUUID, "nonexistent", orgUUID) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("DeleteApiDocument(missing) = %v, want sql.ErrNoRows", err) + } +} + +// DeleteDocument throws sql.ErrNoRows if the row doesn't exist +func TestDocumentRepo_DeleteDocument_ReturnsErrNoRowsWhenMissing(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-del-reserved-missing" + const artifactUUID = "artifact-del-reserved-missing" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + err := repo.DeleteDocument(artifactUUID, constants.DocumentHandleThumbnail, orgUUID, constants.DocumentTypeThumbnail) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("DeleteDocument(missing) = %v, want sql.ErrNoRows", err) + } +} + +// CreateDocument creates any type of doc and returns its UUID. +// The reserved-type guard is a service-layer concern; the repo accepts any +// well-formed row, so test every type path here (one subtest per type). +func TestDocumentRepo_CreateDocument_CreatesAnyTypeAndAssignsUUID(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-create-any" + const artifactUUID = "artifact-create-any" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + + cases := []struct { + name string + docType string + handle string + }{ + {"HOW_TO", constants.DocumentTypeHowTo, "overview"}, + {"THUMBNAIL", constants.DocumentTypeThumbnail, constants.DocumentHandleThumbnail}, + {"DEFINITION", constants.DocumentTypeDefinition, constants.DocumentHandleDefinition}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doc := &model.Document{ + // Leave ID empty — the repo must generate one. + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: tc.docType, Handle: tc.handle, + DisplayName: tc.name + " doc", + Content: []byte("content"), + CreatedBy: "alice", UpdatedBy: "alice", + } + if err := repo.CreateDocument(doc); err != nil { + t.Fatalf("CreateDocument: %v", err) + } + if doc.ID == "" { + t.Fatal("CreateDocument did not populate doc.ID") + } + var persisted string + if err := db.QueryRow(`SELECT uuid FROM api_documents WHERE uuid = ?`, doc.ID).Scan(&persisted); err != nil { + t.Fatalf("look up newly created row: %v", err) + } + if persisted != doc.ID { + t.Errorf("stored uuid = %q, want %q", persisted, doc.ID) + } + }) + } +} + +// GetDocument with docType not empty returns the doc of only that type. +func TestDocumentRepo_GetDocument_WithDocType_ReturnsOnlyThatType(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-get-type" + const artifactUUID = "artifact-get-type" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + }) + + repo := NewDocumentRepo(db) + + doc, err := repo.GetDocument(artifactUUID, constants.DocumentHandleThumbnail, orgUUID, constants.DocumentTypeThumbnail) + if err != nil { + t.Fatalf("GetDocument(matching type): %v", err) + } + if doc == nil { + t.Fatal("GetDocument(matching type) returned nil") + } + if doc.Type != constants.DocumentTypeThumbnail { + t.Errorf("doc.Type = %q, want %q", doc.Type, constants.DocumentTypeThumbnail) + } + + doc, err = repo.GetDocument(artifactUUID, constants.DocumentHandleThumbnail, orgUUID, constants.DocumentTypeHowTo) + if err != nil { + t.Fatalf("GetDocument(wrong type): %v", err) + } + if doc != nil { + t.Errorf("GetDocument(wrong type) returned row of type %q, want nil", doc.Type) + } +} + +// GetDocument with docType empty returns a doc by excluding the reserved +// types (THUMBNAIL, DEFINITION) — the user-facing /docs/{id} path passes "". +func TestDocumentRepo_GetDocument_WithEmptyDocType_ExcludesReservedTypes(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-get-empty" + const artifactUUID = "artifact-get-empty" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-user", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", DisplayName: "Overview", + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + }) + + repo := NewDocumentRepo(db) + + doc, err := repo.GetDocument(artifactUUID, "overview", orgUUID, "") + if err != nil { + t.Fatalf("GetDocument(user handle, empty docType): %v", err) + } + if doc == nil { + t.Fatal("expected user doc to be returned, got nil") + } + + doc, err = repo.GetDocument(artifactUUID, constants.DocumentHandleThumbnail, orgUUID, "") + if err != nil { + t.Fatalf("GetDocument(reserved handle, empty docType): %v", err) + } + if doc != nil { + t.Errorf("reserved row leaked onto empty-docType path, got %+v", doc) + } +} + +// ListDocumentsByArtifact with docType=OTHER returns docs of non-fixed and +// non-reserved types — both plain "Other" (stored as DOC_Other) and docs with +// a user-chosen custom name (stored as DOC_, e.g. "DOC_FAQ"). +func TestDocumentRepo_ListDocumentsByArtifact_WithOtherDocType_ExcludesFixedAndReservedTypes(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-list-other" + const artifactUUID = "artifact-list-other" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-howto", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: "DOC_HowTo", Handle: "howto", DisplayName: "HowTo", + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-sample", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: "DOC_Samples", Handle: "sample", DisplayName: "Sample", + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-faq", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + "FAQ", // user-chosen OTHER name stored with DOC_ prefix + Handle: "faq", DisplayName: "FAQ", + }) + // Plain "Other" with no custom type name is stored as DOC_Other and must + // also appear in ?type=OTHER results (regression for issue #3 fix). + insertDocumentRow(t, db, &model.Document{ + ID: "doc-plain-other", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeOther, // "DOC_Other" + Handle: "plain-other", DisplayName: "Plain Other", + }) + + repo := NewDocumentRepo(db) + docs, total, err := repo.ListDocumentsByArtifact(artifactUUID, orgUUID, constants.DocumentTypeOther, 10, 0) + if err != nil { + t.Fatalf("ListDocumentsByArtifact(OTHER): %v", err) + } + if total != 2 || len(docs) != 2 { + t.Fatalf("expected FAQ and DOC_Other rows (2 total), got total=%d docs=%d", total, len(docs)) + } + handles := make(map[string]bool, len(docs)) + for _, d := range docs { + handles[d.Handle] = true + } + for _, want := range []string{"faq", "plain-other"} { + if !handles[want] { + t.Errorf("expected handle %q in results; got %v", want, handles) + } + } +} + +// UpsertDocument can be used to both insert or update a doc of reserved type. +func TestDocumentRepo_UpsertDocument_InsertsThenUpdatesReservedType(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-upsert" + const artifactUUID = "artifact-upsert" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + + first := &model.Document{ + ID: "doc-first", + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + FileName: "icon-v1.png", ContentType: "image/png", + Content: []byte("png-v1"), + CreatedBy: "alice", UpdatedBy: "alice", + } + if err := repo.UpsertDocument(first); err != nil { + t.Fatalf("UpsertDocument (insert): %v", err) + } + + second := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: "SHOULD NOT APPLY", + FileName: "icon-v2.png", ContentType: "image/png", + Content: []byte("png-v2"), + UpdatedBy: "bob", + } + if err := repo.UpsertDocument(second); err != nil { + t.Fatalf("UpsertDocument (update): %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM api_documents WHERE artifact_uuid = ? AND handle = ?`, + artifactUUID, constants.DocumentHandleThumbnail).Scan(&count); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Fatalf("expected one row after upsert-update, got %d", count) + } + + var fileName, displayName string + var content []byte + if err := db.QueryRow(`SELECT display_name, file_name, content FROM api_documents WHERE artifact_uuid = ? AND handle = ?`, + artifactUUID, constants.DocumentHandleThumbnail).Scan(&displayName, &fileName, &content); err != nil { + t.Fatalf("read upserted row: %v", err) + } + if fileName != "icon-v2.png" { + t.Errorf("file_name = %q, want icon-v2.png", fileName) + } + if string(content) != "png-v2" { + t.Errorf("content = %q, want png-v2", content) + } + if displayName != constants.DocumentDisplayNameThumbnail { + t.Errorf("display_name = %q, want unchanged %q — upsert SET list must not mutate display_name", + displayName, constants.DocumentDisplayNameThumbnail) + } +} + +// UpdateApiDocument can be used to update a doc of non-reserved type, cannot +// update doc of reserved type. +func TestDocumentRepo_UpdateApiDocument_UpdatesUserRowRefusesReserved(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-update" + const artifactUUID = "artifact-update" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-user", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", DisplayName: "Overview", + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + }) + + repo := NewDocumentRepo(db) + + // Non-reserved: succeeds, display_name mutates. + userUpdate := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeHowTo, Handle: "overview", + DisplayName: "Overview v2", UpdatedBy: "alice", + } + if err := repo.UpdateApiDocument(userUpdate, false); err != nil { + t.Fatalf("UpdateApiDocument(user): %v", err) + } + var updatedName string + if err := db.QueryRow(`SELECT display_name FROM api_documents WHERE uuid = ?`, "doc-user").Scan(&updatedName); err != nil { + t.Fatalf("read user row after update: %v", err) + } + if updatedName != "Overview v2" { + t.Errorf("user display_name = %q, want %q", updatedName, "Overview v2") + } + + // Reserved: refused via sql.ErrNoRows, row untouched. + reservedUpdate := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: "SHOULD NOT APPLY", UpdatedBy: "alice", + } + err := repo.UpdateApiDocument(reservedUpdate, false) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("UpdateApiDocument(reserved) = %v, want sql.ErrNoRows", err) + } + var thumbName string + if err := db.QueryRow(`SELECT display_name FROM api_documents WHERE uuid = ?`, "doc-thumb").Scan(&thumbName); err != nil { + t.Fatalf("read thumbnail row after refused update: %v", err) + } + if thumbName != constants.DocumentDisplayNameThumbnail { + t.Errorf("thumbnail display_name = %q, want unchanged %q", thumbName, constants.DocumentDisplayNameThumbnail) + } +} + +// A user doc that shares its display name with a THUMBNAIL/DEFINITION row +// must NOT register as a duplicate — the reserved rows live under their own +// endpoints and share the artifact's handle space. +func TestDocumentRepo_DocumentDisplayNameExistsForArtifact_ExcludesReservedRows(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-dn-reserved" + const artifactUUID = "artifact-dn-reserved" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + // Thumbnail and definition rows both carry display names that could + // collide with a user doc. The reserved-type filter must hide them. + insertDocumentRow(t, db, &model.Document{ + ID: "thumb", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeThumbnail, Handle: constants.DocumentHandleThumbnail, + DisplayName: "Guide", Content: []byte{0x89}, + }) + insertDocumentRow(t, db, &model.Document{ + ID: "def", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypeDefinition, Handle: constants.DocumentHandleDefinition, + DisplayName: "Guide", Content: []byte("{}"), + }) + + repo := NewDocumentRepo(db) + exists, err := repo.DocumentDisplayNameExistsForArtifact(artifactUUID, "Guide", "") + if err != nil { + t.Fatalf("err = %v", err) + } + if exists { + t.Error("display name must not conflict with reserved-type rows") + } +} + +func TestDocumentRepo_DocumentDisplayNameExistsForArtifact_UserRowConflicts(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-dn-user" + const artifactUUID = "artifact-dn-user" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "existing", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "existing", + DisplayName: "Guide", Content: []byte("# existing"), + }) + + repo := NewDocumentRepo(db) + exists, err := repo.DocumentDisplayNameExistsForArtifact(artifactUUID, "Guide", "") + if err != nil { + t.Fatalf("err = %v", err) + } + if !exists { + t.Error("display name 'Guide' is in use by the user doc 'existing' — must register as a duplicate") + } +} + +// excludeHandle is how a rename / in-place update excludes its OWN row from the +// uniqueness check: a doc keeping its display name on an update must not read +// as a self-conflict. +func TestDocumentRepo_DocumentDisplayNameExistsForArtifact_ExcludeHandleSkipsSelf(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-dn-exclude" + const artifactUUID = "artifact-dn-exclude" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + insertDocumentRow(t, db, &model.Document{ + ID: "self", ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "self", + DisplayName: "Guide", Content: []byte("# self"), + }) + + repo := NewDocumentRepo(db) + // Without exclude: the row DOES register as a duplicate. + exists, err := repo.DocumentDisplayNameExistsForArtifact(artifactUUID, "Guide", "") + if err != nil { + t.Fatalf("err (no exclude) = %v", err) + } + if !exists { + t.Error("without exclude, the own row must still appear as a conflict") + } + // With exclude: the row is skipped — no false self-conflict on update. + exists, err = repo.DocumentDisplayNameExistsForArtifact(artifactUUID, "Guide", "self") + if err != nil { + t.Fatalf("err (with exclude) = %v", err) + } + if exists { + t.Error("the own row must be skipped when excludeHandle is set") + } +} + +func TestDocumentRepo_DocumentDisplayNameExistsForArtifact_UnknownNameIsFalse(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-dn-missing" + const artifactUUID = "artifact-dn-missing" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + exists, err := repo.DocumentDisplayNameExistsForArtifact(artifactUUID, "Nothing", "") + if err != nil { + t.Fatalf("err = %v", err) + } + if exists { + t.Error("no row carries 'Nothing' — must return false, not forward the ErrNoRows") + } +} + +// --------------------------------------------------------------------------- +// GetDocumentUUIDsByHandles — handle → uuid, scoped to artifact +// --------------------------------------------------------------------------- + +func TestDocumentRepo_GetDocumentUUIDsByHandles_ResolvesHandlesWithinArtifact(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-u2h" + const artifactA = "artifact-u2h-A" + const artifactB = "artifact-u2h-B" + // First call seeds org + project + artifact; second artifact under the + // same org only needs its own project + artifact row (seedTestArtifact + // would re-insert the org and trip the organizations.handle unique index). + seedTestArtifact(t, db, orgUUID, artifactA) + seedExtraArtifact(t, db, orgUUID, artifactB) + + // Both artifacts have a doc under handle "overview": the mapping must be + // scoped per artifact (artifact_uuid, handle is the real unique index), + // so a lookup against A must not return B's uuid. + insertDocumentRow(t, db, &model.Document{ + ID: "doc-A", ArtifactUUID: artifactA, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "overview", + DisplayName: "Overview", Content: []byte("# A"), + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-B", ArtifactUUID: artifactB, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "overview", + DisplayName: "Overview", Content: []byte("# B"), + }) + // And a second handle under A, so the function has more than one row to + // return and the test actually covers the loop. + insertDocumentRow(t, db, &model.Document{ + ID: "doc-A-faq", ArtifactUUID: artifactA, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + "FAQ", Handle: "faq", + DisplayName: "FAQ", Content: []byte("# faq"), + }) + + repo := NewDocumentRepo(db) + m, err := repo.GetDocumentUUIDsByHandles(artifactA, []string{"overview", "faq", "missing"}, orgUUID) + if err != nil { + t.Fatalf("err = %v", err) + } + if m["overview"] != "doc-A" { + t.Errorf("overview = %q, want doc-A (artifact B's uuid must NOT leak)", m["overview"]) + } + if m["faq"] != "doc-A-faq" { + t.Errorf("faq = %q, want doc-A-faq", m["faq"]) + } + if _, ok := m["missing"]; ok { + t.Errorf("missing handle must not appear in result; got %v", m) + } +} + +func TestDocumentRepo_GetDocumentUUIDsByHandles_EmptyInputReturnsEmptyMap(t *testing.T) { + // Guard against the SQL `IN ()` degenerate case: callers batch up handles + // and may legitimately pass an empty slice. The function must short-circuit + // before running a query against zero placeholders. + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + repo := NewDocumentRepo(db) + + m, err := repo.GetDocumentUUIDsByHandles("any", nil, "any-org") + if err != nil { + t.Fatalf("err = %v", err) + } + if m == nil || len(m) != 0 { + t.Errorf("result = %+v, want an empty (non-nil) map", m) + } +} + +// --------------------------------------------------------------------------- +// GetDocumentHandlesByUUIDs — the inverse direction +// --------------------------------------------------------------------------- + +func TestDocumentRepo_GetDocumentHandlesByUUIDs_ScopedByOrganization(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + // Two orgs, each with a doc; the function must only resolve uuids that + // belong to the requested organization, not every row that happens to + // carry the uuid. + const orgA = "org-h2u-A" + const orgB = "org-h2u-B" + seedTestArtifact(t, db, orgA, "artifact-h2u-A") + seedTestArtifact(t, db, orgB, "artifact-h2u-B") + + insertDocumentRow(t, db, &model.Document{ + ID: "doc-A", ArtifactUUID: "artifact-h2u-A", OrganizationUUID: orgA, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "overview-a", + DisplayName: "Overview A", Content: []byte("# a"), + }) + insertDocumentRow(t, db, &model.Document{ + ID: "doc-B", ArtifactUUID: "artifact-h2u-B", OrganizationUUID: orgB, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "overview-b", + DisplayName: "Overview B", Content: []byte("# b"), + }) + + repo := NewDocumentRepo(db) + m, err := repo.GetDocumentHandlesByUUIDs([]string{"doc-A", "doc-B", "missing"}, orgA) + if err != nil { + t.Fatalf("err = %v", err) + } + if m["doc-A"] != "overview-a" { + t.Errorf("doc-A = %q, want overview-a", m["doc-A"]) + } + if _, ok := m["doc-B"]; ok { + t.Errorf("doc-B must not leak into another org's lookup; got %v", m) + } + if _, ok := m["missing"]; ok { + t.Errorf("missing uuid must not appear in result; got %v", m) + } +} + +func TestDocumentRepo_GetDocumentHandlesByUUIDs_EmptyInputReturnsEmptyMap(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + repo := NewDocumentRepo(db) + + m, err := repo.GetDocumentHandlesByUUIDs(nil, "any-org") + if err != nil { + t.Fatalf("err = %v", err) + } + if m == nil || len(m) != 0 { + t.Errorf("result = %+v, want an empty (non-nil) map", m) + } +} + +// --------------------------------------------------------------------------- +// UpsertDocument — the UPDATE branch +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// sortedMapKeys — pure helper +// --------------------------------------------------------------------------- + +// sortedMapKeys returns the keys of a set-shaped `map[string]bool` in sorted +// order. Direct test — same package, no DB needed. +func TestSortedMapKeys(t *testing.T) { + cases := []struct { + name string + in map[string]bool + want []string + }{ + {"empty map", map[string]bool{}, []string{}}, + {"single key", map[string]bool{"a": true}, []string{"a"}}, + {"keys returned in sorted order regardless of insertion", map[string]bool{"c": true, "a": true, "b": true}, []string{"a", "b", "c"}}, + {"false-valued keys still appear (set membership is key presence, not value)", map[string]bool{"x": false, "a": true}, []string{"a", "x"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sortedMapKeys(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("len(result) = %d, want %d; got %v", len(got), len(tc.want), got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("result[%d] = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +// Existing tests cover the INSERT branch of UpsertDocument via +// UpsertDocument_InsertsThenUpdatesReservedType. This one targets a +// user-type row to pin the generic UPDATE branch and its deliberate narrow +// mutation set. +// +// UpsertDocument's UPDATE path only writes file_name, content_type, content, +// updated_by and updated_at — NOT display_name and NOT created_by. That is +// the right contract for a thumbnail-style upsert (display name is a fixed +// label; the author of record stays whoever uploaded it first). The test +// pins all four halves so a future "convenience" edit of the SET clause can't +// silently start overwriting display_name on upsert. +func TestDocumentRepo_UpsertDocument_UpdateNarrowMutation(t *testing.T) { + db, cleanup := setupTestDB(t) + t.Cleanup(cleanup) + + const orgUUID = "org-upsert-update" + const artifactUUID = "artifact-upsert-update" + seedTestArtifact(t, db, orgUUID, artifactUUID) + + repo := NewDocumentRepo(db) + + // First upsert → INSERT branch (no existing row). + first := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + Handle: "guide", + DisplayName: "Original Display Name", + FileName: "first.md", + ContentType: "text/markdown; charset=utf-8", + Content: []byte("# first"), + CreatedBy: "alice", + UpdatedBy: "alice", + } + if err := repo.UpsertDocument(first); err != nil { + t.Fatalf("first upsert: %v", err) + } + + // Second upsert on the same (artifact_uuid, handle, type) → UPDATE branch. + // Every field the function MIGHT touch is given a new value so the + // assertions below can tell exactly what the SQL writes and what it leaves. + second := &model.Document{ + ArtifactUUID: artifactUUID, OrganizationUUID: orgUUID, + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + Handle: "guide", + DisplayName: "Changed Display Name", // Expected IGNORED on UPDATE. + FileName: "second.md", + ContentType: "text/plain", + Content: []byte("# second"), + CreatedBy: "bob", // Expected IGNORED on UPDATE: created_by is immutable. + UpdatedBy: "bob", + } + if err := repo.UpsertDocument(second); err != nil { + t.Fatalf("second upsert: %v", err) + } + + got, err := repo.GetDocument(artifactUUID, "guide", orgUUID, "") + if err != nil || got == nil { + t.Fatalf("GetDocument after update: %v %+v", err, got) + } + // Fields that UpsertDocument's UPDATE clause writes: + if string(got.Content) != "# second" { + t.Errorf("content = %q, want '# second' — UPDATE must replace the body", got.Content) + } + if got.FileName != "second.md" { + t.Errorf("file_name = %q, want second.md", got.FileName) + } + if got.ContentType != "text/plain" { + t.Errorf("content_type = %q, want text/plain", got.ContentType) + } + if got.UpdatedBy != "bob" { + t.Errorf("updated_by = %q, want bob", got.UpdatedBy) + } + // Fields the UPDATE clause deliberately omits: + if got.DisplayName != "Original Display Name" { + t.Errorf("display_name = %q, want unchanged — UPSERT UPDATE must not overwrite display_name", got.DisplayName) + } + if got.CreatedBy != "alice" { + t.Errorf("created_by = %q, want unchanged alice — UPSERT UPDATE must not overwrite created_by", got.CreatedBy) + } +} diff --git a/platform-api/internal/repository/interfaces.go b/platform-api/internal/repository/interfaces.go index 0cb36f9e31..f4bbe5492e 100644 --- a/platform-api/internal/repository/interfaces.go +++ b/platform-api/internal/repository/interfaces.go @@ -517,11 +517,14 @@ type CustomPolicyRepository interface { // DocumentRepository defines the interface for document persistence. type DocumentRepository interface { CreateDocument(doc *model.Document) error - GetDocumentByArtifactAndHandle(artifactUUID, handle, orgUUID string) (*model.Document, error) - GetDocumentByArtifactAndType(artifactUUID, docType, orgUUID string) (*model.Document, error) + GetDocument(artifactUUID, handle, orgUUID, docType string) (*model.Document, error) + ListDocumentsByArtifact(artifactUUID, orgUUID, docType string, limit, offset int) ([]*model.Document, int, error) UpsertDocument(doc *model.Document) error - DeleteDocument(artifactUUID, handle, orgUUID string) error + UpdateApiDocument(doc *model.Document, updateContent bool) error + DeleteApiDocument(artifactUUID, handle, orgUUID string) error + DeleteDocument(artifactUUID, handle, orgUUID, docType string) error DocumentHandleExistsForArtifact(artifactUUID, handle string) (bool, error) + DocumentDisplayNameExistsForArtifact(artifactUUID, displayName, excludeHandle string) (bool, error) // GetDocumentUUIDsByHandles resolves each handle to its document uuid, // scoped to one artifact (api_documents' real unique index is // (artifact_uuid, handle) — a handle is only guaranteed unique per diff --git a/platform-api/internal/server/scope_route_coverage_test.go b/platform-api/internal/server/scope_route_coverage_test.go index baf7f38d1d..4c21c80df3 100644 --- a/platform-api/internal/server/scope_route_coverage_test.go +++ b/platform-api/internal/server/scope_route_coverage_test.go @@ -45,6 +45,7 @@ func registerAllRoutes(mux *http.ServeMux) { handler.NewProjectHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewApplicationHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewAPIHandler(nil, nil, nil, logger, nil).RegisterRoutes(mux) + handler.NewAPIDocumentHandler(nil, nil, logger, nil).RegisterRoutes(mux) handler.NewGatewayHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewSubscriptionHandler(nil, nil, nil, logger).RegisterRoutes(mux) handler.NewSubscriptionPlanHandler(nil, nil, logger).RegisterRoutes(mux) diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 9461e7b9f9..ab69ea8717 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -273,7 +273,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, appService := service.NewApplicationService(appRepo, projectRepo, orgRepo, apiRepo, gatewayEventsService, auditRepo, identityService, slogger) apiService := service.NewAPIService(apiRepo, projectRepo, orgRepo, gatewayRepo, deploymentRepo, subscriptionPlanRepo, customPolicyRepo, gatewayEventsService, apiUtil, slogger, auditRepo, identityService) - apiDocumentService := service.NewAPIDocumentService(documentRepo, auditRepo, slogger) + apiDocumentService := service.NewAPIDocumentService(documentRepo, artifactRepo, auditRepo, slogger) gatewayService := service.NewGatewayService(gatewayRepo, orgRepo, apiRepo, customPolicyRepo, gatewayEventsService, slogger, cfg.Gateway.EnableVersionVerification, cfg.Gateway.EnableFunctionalityTypeVerification, auditRepo, identityService) subscriptionService := service.NewSubscriptionService(apiRepo, artifactRepo, subscriptionRepo, subscriptionPlanRepo, orgRepo, gatewayEventsService, auditRepo, slogger) subscriptionPlanService := service.NewSubscriptionPlanService(subscriptionPlanRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, slogger) @@ -400,6 +400,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, orgHandler := handler.NewOrganizationHandler(orgService, identityService, slogger) projectHandler := handler.NewProjectHandler(projectService, identityService, slogger) apiHandler := handler.NewAPIHandler(apiService, identityService, apiDocumentService, slogger, cfg) + apiDocumentHandler := handler.NewAPIDocumentHandler(apiDocumentService, identityService, slogger, cfg) + apiThumbnailHandler := handler.NewAPIThumbnailHandler(apiDocumentService, identityService, slogger, cfg) gatewayHandler := handler.NewGatewayHandler(gatewayService, identityService, slogger) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService, subscriptionPlanService, identityService, slogger) subscriptionPlanHandler := handler.NewSubscriptionPlanHandler(subscriptionPlanService, identityService, slogger) @@ -480,6 +482,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, appHandler.RegisterRoutes(core) apiPortalHandler.RegisterRoutes(core) apiHandler.RegisterRoutes(core) + apiDocumentHandler.RegisterRoutes(core) + apiThumbnailHandler.RegisterRoutes(core) gatewayHandler.RegisterRoutes(core) subscriptionHandler.RegisterRoutes(core) subscriptionPlanHandler.RegisterRoutes(core) diff --git a/platform-api/internal/service/api_document.go b/platform-api/internal/service/api_document.go index 8f2909d745..07f2743a3f 100644 --- a/platform-api/internal/service/api_document.go +++ b/platform-api/internal/service/api_document.go @@ -18,7 +18,11 @@ package service import ( + "database/sql" + "errors" + "fmt" "log/slog" + "net/http" "path/filepath" "strings" @@ -36,20 +40,45 @@ import ( // It manages document CRUD operations and OpenAPI spec validation. type APIDocumentService struct { documentRepo repository.DocumentRepository + artifactRepo repository.ArtifactRepository auditRepo repository.AuditRepository slogger *slog.Logger } -// NewAPIDocumentService creates a new API document service -func NewAPIDocumentService(documentRepo repository.DocumentRepository, auditRepo repository.AuditRepository, slogger *slog.Logger) *APIDocumentService { +// NewAPIDocumentService creates a new API document service. artifactRepo is +// required for the /apis/{apiType}/{apiId}/docs endpoints to resolve a +// kind-aware handle to an artifact UUID; nil is acceptable only in tests that +// never call ResolveArtifactUUID. +func NewAPIDocumentService(documentRepo repository.DocumentRepository, artifactRepo repository.ArtifactRepository, auditRepo repository.AuditRepository, slogger *slog.Logger) *APIDocumentService { return &APIDocumentService{ documentRepo: documentRepo, + artifactRepo: artifactRepo, auditRepo: auditRepo, slogger: slogger, } } -// CreateDocument creates a new OpenAPI spec document for an artifact. +// ResolveArtifactUUID resolves (apiType, apiId) to the artifact's internal UUID, scoped to orgID. +// An unrecognised apiType and an unknown apiId both collapse to the same NotFound. +func (s *APIDocumentService) ResolveArtifactUUID(apiType, apiID, orgID string) (string, error) { + if apiType == "" || apiID == "" { + return "", apperror.NotFound.New() + } + metadata, err := s.artifactRepo.GetAPIMetadataByHandleAndKind(apiID, apiType, orgID) + if errors.Is(err, repository.ErrUnknownArtifactKind) { + return "", apperror.NotFound.New() + } + if err != nil { + return "", fmt.Errorf("failed to resolve artifact by handle and kind: %w", err) + } + if metadata == nil { + return "", apperror.NotFound.New() + } + return metadata.ID, nil +} + +// CreateDocument creates a document for a reserved type +// (DEFINITION / THUMBNAIL) that is managed via its own dedicated endpoints. func (s *APIDocumentService) CreateDocument(req *dto.CreateAPIDocumentRequest, orgId string, userId string, artifactUUID string) (string, error) { if req == nil { return "", apperror.ValidationFailed.New("document request is required") @@ -65,13 +94,16 @@ func (s *APIDocumentService) CreateDocument(req *dto.CreateAPIDocumentRequest, o Handle: req.Handle, DisplayName: req.DisplayName, FileName: req.FileName, - ContentType: s.GetSpecContentType(req.Content), + ContentType: s.contentTypeForDocType(req.Type, req.Content), Content: req.Content, CreatedBy: userId, } if doc.Handle == "" { handle, handleErr := utils.GenerateHandle(doc.DisplayName, func(candidate string) bool { + if constants.ReservedAPIDocumentHandles[candidate] { + return true + } exists, err := s.documentRepo.DocumentHandleExistsForArtifact(doc.ArtifactUUID, candidate) if err != nil { return true @@ -85,43 +117,114 @@ func (s *APIDocumentService) CreateDocument(req *dto.CreateAPIDocumentRequest, o doc.Handle = handle } + if doc.FileName == "" && strings.HasPrefix(doc.ContentType, "text/markdown") { + doc.FileName = doc.Handle + ".md" + } + if err := s.documentRepo.CreateDocument(doc); err != nil { + if repository.IsUniqueViolation(err) { + return "", apperror.Conflict.New().WithLogMessage("document handle already exists for artifact") + } s.slogger.Error("Failed to create document", "artifactUUID", doc.ArtifactUUID, "error", err) return "", err } - if err := s.auditRepo.Record("CREATE", doc.ArtifactUUID, "api_definition", doc.OrganizationUUID, doc.CreatedBy); err != nil { + if err := s.auditRepo.Record("CREATE", doc.ArtifactUUID, auditResourceTypeFor(doc.Type), doc.OrganizationUUID, doc.CreatedBy); err != nil { s.slogger.Error("Failed to record audit entry for document create", "artifactUUID", doc.ArtifactUUID, "error", err) } return doc.Handle, nil } -// GetDocument retrieves an OpenAPI spec document by artifact UUID and org. -func (s *APIDocumentService) GetDocument(artifactUUID, orgId string) (*dto.APIDocumentContent, error) { - if artifactUUID == "" { - return nil, apperror.ValidationFailed.New("artifact UUID is required") +var normalizeDocTypeMap = map[string]string{ + "howto": constants.DocumentTypeHowTo, + "samples": constants.DocumentTypeSamples, + "supportforum": constants.DocumentTypeSupportForum, + "publicforum": constants.DocumentTypePublicForum, + "other": constants.DocumentTypeOther, +} + +func NormalizeAPIDocumentType(input string) (string, bool) { + canonical, ok := normalizeDocTypeMap[strings.ToLower(strings.TrimSpace(input))] + return canonical, ok +} + +func decodeStoredDocType(stored string) string { + if after, found := strings.CutPrefix(stored, constants.DocumentTypePrefix); found { + return after } + return stored +} - doc, err := s.documentRepo.GetDocumentByArtifactAndType(artifactUUID, constants.DocumentTypeDefinition, orgId) - if err != nil { - s.slogger.Error("Failed to get document", "artifactUUID", artifactUUID, "error", err) - return nil, err +// resolveStoredDocType computes the value to persist in the type column. +// All types are stored with the DOC_ prefix so the api-portal can identify +// them as documents after the API is published. decodeStoredDocType strips +// the prefix on read, so the API always returns the bare name (e.g. "FAQ"). +func resolveStoredDocType(docType, otherTypeName string) string { + if docType != constants.DocumentTypeOther { + return constants.DocumentTypePrefix + docType + } + if trimmed := strings.TrimSpace(otherTypeName); trimmed != "" { + return constants.DocumentTypePrefix + trimmed } + return constants.DocumentTypePrefix + constants.DocumentTypeOther +} - if doc == nil { - return nil, apperror.NotFound.New() +// CreateApiDocument creates a user-authored document attached to an artifact. +func (s *APIDocumentService) CreateApiDocument(req *dto.CreateAPIDocumentRequest, orgID, userID, artifactUUID string) (string, error) { + if req == nil { + return "", apperror.ValidationFailed.New("document request is required") + } + if artifactUUID == "" { + return "", apperror.ValidationFailed.New("artifact UUID is required") + } + if !constants.ValidAPIDocumentUserTypes[req.Type] { + return "", apperror.ValidationFailed.New("invalid document type") + } + if req.Type == constants.DocumentTypeOther { + trimmed := strings.TrimSpace(req.OtherTypeName) + if trimmed != "" { + if constants.ForbiddenOtherTypeNames[strings.ToLower(trimmed)] { + return "", apperror.ValidationFailed.New("otherTypeName cannot be a reserved or fixed document type name") + } + if maxName := maxDocTypeLen - len(constants.DocumentTypePrefix); len(trimmed) > maxName { + return "", apperror.ValidationFailed.New(fmt.Sprintf("otherTypeName must be at most %d characters", maxName)) + } + } + } + req.Type = resolveStoredDocType(req.Type, req.OtherTypeName) + if strings.TrimSpace(req.DisplayName) == "" { + return "", apperror.ValidationFailed.New("displayName is required") + } + if len(req.DisplayName) > maxDocDisplayNameLen { + return "", apperror.ValidationFailed.New(fmt.Sprintf("displayName must be at most %d characters", maxDocDisplayNameLen)) + } + if len(req.FileName) > maxDocFileNameLen { + return "", apperror.ValidationFailed.New(fmt.Sprintf("fileName must be at most %d characters", maxDocFileNameLen)) + } + if req.Handle != "" { + if err := utils.ValidateHandle(req.Handle); err != nil { + return "", err + } + if constants.ReservedAPIDocumentHandles[req.Handle] { + return "", apperror.ValidationFailed.New("id is reserved for a system-managed document") + } + exists, existsErr := s.documentRepo.DocumentHandleExistsForArtifact(artifactUUID, req.Handle) + if existsErr != nil { + s.slogger.Error("Failed to check document handle existence", "artifactUUID", artifactUUID, "handle", req.Handle, "error", existsErr) + return "", apperror.Internal.Wrap(existsErr).WithLogMessage("failed to validate document handle") + } + if exists { + return "", apperror.Conflict.New().WithLogMessage("document handle already exists for artifact") + } } - return &dto.APIDocumentContent{ - Content: doc.Content, - ContentType: doc.ContentType, - }, nil + return s.CreateDocument(req, orgID, userID, artifactUUID) } -// PutDocument updates or creates an OpenAPI spec document for an artifact. +// UpsertDocument updates or creates a document for an artifact. // If a document of the same type already exists, it is updated in-place. // If no document exists, a new one is created. -func (s *APIDocumentService) PutDocument(req *dto.PutAPIDocumentRequest, orgId string, userId string, artifactUUID string) error { +func (s *APIDocumentService) UpsertDocument(req *dto.CreateAPIDocumentRequest, orgId string, userId string, artifactUUID string) error { if req == nil { return apperror.ValidationFailed.New("document request is required") } @@ -136,12 +239,12 @@ func (s *APIDocumentService) PutDocument(req *dto.PutAPIDocumentRequest, orgId s Handle: req.Handle, DisplayName: req.DisplayName, FileName: req.FileName, - ContentType: s.GetSpecContentType(req.Content), + ContentType: s.contentTypeForDocType(req.Type, req.Content), Content: req.Content, UpdatedBy: userId, } - existing, err := s.documentRepo.GetDocumentByArtifactAndType(doc.ArtifactUUID, doc.Type, doc.OrganizationUUID) + existing, err := s.documentRepo.GetDocument(doc.ArtifactUUID, doc.Handle, doc.OrganizationUUID, doc.Type) if err != nil { s.slogger.Error("Failed to check existing document", "artifactUUID", doc.ArtifactUUID, "error", err) return err @@ -154,6 +257,9 @@ func (s *APIDocumentService) PutDocument(req *dto.PutAPIDocumentRequest, orgId s doc.CreatedBy = userId if doc.Handle == "" { handle, handleErr := utils.GenerateHandle(doc.DisplayName, func(candidate string) bool { + if constants.ReservedAPIDocumentHandles[candidate] { + return true + } exists, err := s.documentRepo.DocumentHandleExistsForArtifact(doc.ArtifactUUID, candidate) if err != nil { return true @@ -177,25 +283,224 @@ func (s *APIDocumentService) PutDocument(req *dto.PutAPIDocumentRequest, orgId s if isUpdate { action = "UPDATE" } - if err := s.auditRepo.Record(action, doc.ArtifactUUID, "api_definition", doc.OrganizationUUID, doc.UpdatedBy); err != nil { + if err := s.auditRepo.Record(action, doc.ArtifactUUID, auditResourceTypeFor(doc.Type), doc.OrganizationUUID, doc.UpdatedBy); err != nil { s.slogger.Error("Failed to record audit entry for document upsert", "artifactUUID", doc.ArtifactUUID, "error", err) } return nil } -// DeleteDocument deletes a document for an artifact identified by its handle. -func (s *APIDocumentService) DeleteDocument(artifactUUID, handle, orgId string) error { +// auditResourceTypeFor maps a document type to its audit resource-type label. +func auditResourceTypeFor(docType string) string { + switch docType { + case constants.DocumentTypeDefinition: + return "api_definition" + case constants.DocumentTypeThumbnail: + return "api_thumbnail" + default: + return "api_document" + } +} + +// DeleteUserDocument deletes a user-authored document identified by handle. +// Refuses to delete a DEFINITION/THUMBNAIL document +func (s *APIDocumentService) DeleteApiDocument(artifactUUID, handle, orgID, userID string) error { if artifactUUID == "" { return apperror.ValidationFailed.New("artifact UUID is required") } if handle == "" { return apperror.ValidationFailed.New("document handle is required") } + if constants.ReservedAPIDocumentHandles[handle] { + return apperror.ValidationFailed.New("cannot delete a system-managed document via this endpoint") + } - if err := s.documentRepo.DeleteDocument(artifactUUID, handle, orgId); err != nil { + // docType="" additionally excludes any reserved-type row at the repo layer as defense-in-depth. + existing, err := s.documentRepo.GetDocument(artifactUUID, handle, orgID, "") + if err != nil { + s.slogger.Error("Failed to load document for delete", "artifactUUID", artifactUUID, "handle", handle, "error", err) + return err + } + if existing == nil { + return apperror.NotFound.New() + } + + if err := s.documentRepo.DeleteApiDocument(artifactUUID, handle, orgID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return apperror.NotFound.New() + } s.slogger.Error("Failed to delete document", "artifactUUID", artifactUUID, "handle", handle, "error", err) return err } + if err := s.auditRepo.Record("DELETE", artifactUUID, "api_document", orgID, userID); err != nil { + s.slogger.Error("Failed to record audit entry for document delete", "artifactUUID", artifactUUID, "error", err) + } + return nil +} + +// GetAllApiDocuments returns a page of user-facing documents attached to +// artifactUUID, optionally filtered by docType. Reserved types (DEFINITION, +// THUMBNAIL) are excluded by the repository at the SQL layer. +func (s *APIDocumentService) GetAllApiDocuments(artifactUUID, orgID, docType string, limit, offset int) ([]api.APIDocumentMetadata, int, error) { + if artifactUUID == "" { + return nil, 0, apperror.ValidationFailed.New("artifact UUID is required") + } + + docs, total, err := s.documentRepo.ListDocumentsByArtifact(artifactUUID, orgID, docType, limit, offset) + if err != nil { + s.slogger.Error("Failed to list documents", "artifactUUID", artifactUUID, "error", err) + return nil, 0, err + } + items := make([]api.APIDocumentMetadata, 0, len(docs)) + for _, d := range docs { + items = append(items, modelToAPIMetadata(d)) + } + return items, total, nil +} + +// GetDocument retrieves document metadata by handle, scoped to artifactUUID + orgID. +// Reserved types (DEFINITION, THUMBNAIL) are excluded by the repository. +func (s *APIDocumentService) GetDocument(artifactUUID, handle, orgID string) (*api.APIDocumentMetadata, error) { + if artifactUUID == "" { + return nil, apperror.ValidationFailed.New("artifact UUID is required") + } + if handle == "" { + return nil, apperror.ValidationFailed.New("document handle is required") + } + + doc, err := s.documentRepo.GetDocument(artifactUUID, handle, orgID, "") + if err != nil { + s.slogger.Error("Failed to get document", "artifactUUID", artifactUUID, "handle", handle, "error", err) + return nil, err + } + if doc == nil { + return nil, apperror.NotFound.New() + } + resp := modelToAPIMetadata(doc) + return &resp, nil +} + +// GetDocumentWithContent fetches a document including its raw content bytes. +func (s *APIDocumentService) GetDocumentWithContent(artifactUUID, handle, orgID, docType string) (*api.APIDocumentMetadata, []byte, error) { + if artifactUUID == "" { + return nil, nil, apperror.ValidationFailed.New("artifact UUID is required") + } + if handle == "" { + return nil, nil, apperror.ValidationFailed.New("document handle is required") + } + + doc, err := s.documentRepo.GetDocument(artifactUUID, handle, orgID, docType) + if err != nil { + s.slogger.Error("Failed to get document", "artifactUUID", artifactUUID, "handle", handle, "error", err) + return nil, nil, err + } + if doc == nil { + return nil, nil, apperror.NotFound.New() + } + resp := modelToAPIMetadata(doc) + return &resp, doc.Content, nil +} + +// modelToAPIMetadata converts a model.Document to the generated API metadata type returned on the wire. +func modelToAPIMetadata(d *model.Document) api.APIDocumentMetadata { + return api.APIDocumentMetadata{ + Id: d.Handle, + Type: decodeStoredDocType(d.Type), + DisplayName: d.DisplayName, + FileName: utils.StringPtrIfNotEmpty(d.FileName), + ContentType: utils.StringPtrIfNotEmpty(d.ContentType), + CreatedBy: utils.StringPtrIfNotEmpty(d.CreatedBy), + CreatedAt: utils.TimePtrIfNotZero(d.CreatedAt), + UpdatedBy: utils.StringPtrIfNotEmpty(d.UpdatedBy), + UpdatedAt: utils.TimePtrIfNotZero(d.UpdatedAt), + } +} + +// UpdateUserDocument applies a partial update to a user-authored document. +// Each non-nil pointer field in req replaces the stored value; req.Content +// (non-nil) replaces the stored bytes along with ContentType and FileName. +// Type is validated against ValidAPIDocumentUserTypes so a PUT cannot morph +// a user doc into the singleton DEFINITION type. +func (s *APIDocumentService) UpdateApiDocument(req *dto.UpdateAPIDocumentRequest, orgID, userID, artifactUUID, handle string) error { + if req == nil { + return apperror.ValidationFailed.New("document request is required") + } + if artifactUUID == "" { + return apperror.ValidationFailed.New("artifact UUID is required") + } + if handle == "" { + return apperror.ValidationFailed.New("document handle is required") + } + if err := utils.ValidateHandle(handle); err != nil { + return err + } + if constants.ReservedAPIDocumentHandles[handle] { + return apperror.ValidationFailed.New("cannot update a system-managed document via this endpoint") + } + + // docType="" additionally excludes any reserved-type row at the repo layer as defense-in-depth. + existing, err := s.documentRepo.GetDocument(artifactUUID, handle, orgID, "") + if err != nil { + s.slogger.Error("Failed to load document for update", "artifactUUID", artifactUUID, "handle", handle, "error", err) + return err + } + if existing == nil { + return apperror.NotFound.New() + } + + updatedDocument := *existing + updatedDocument.UpdatedBy = userID + if req.Type != nil { + newType := strings.TrimSpace(*req.Type) + if !constants.ValidAPIDocumentUserTypes[newType] { + return apperror.ValidationFailed.New("invalid document type") + } + if newType == constants.DocumentTypeOther { + trimmed := strings.TrimSpace(req.OtherTypeName) + if trimmed != "" { + if constants.ForbiddenOtherTypeNames[strings.ToLower(trimmed)] { + return apperror.ValidationFailed.New("otherTypeName cannot be a reserved or fixed document type name") + } + if maxName := maxDocTypeLen - len(constants.DocumentTypePrefix); len(trimmed) > maxName { + return apperror.ValidationFailed.New(fmt.Sprintf("otherTypeName must be at most %d characters", maxName)) + } + } + } + updatedDocument.Type = resolveStoredDocType(newType, req.OtherTypeName) + } + if req.DisplayName != nil { + trimmed := strings.TrimSpace(*req.DisplayName) + if trimmed == "" { + return apperror.ValidationFailed.New("displayName must not be empty") + } + if len(trimmed) > maxDocDisplayNameLen { + return apperror.ValidationFailed.New(fmt.Sprintf("displayName must be at most %d characters", maxDocDisplayNameLen)) + } + updatedDocument.DisplayName = trimmed + } + if req.FileName != nil { + if len(*req.FileName) > maxDocFileNameLen { + return apperror.ValidationFailed.New(fmt.Sprintf("fileName must be at most %d characters", maxDocFileNameLen)) + } + updatedDocument.FileName = *req.FileName + } + updateContent := req.Content != nil + if updateContent { + updatedDocument.Content = req.Content + if req.ContentType != nil { + updatedDocument.ContentType = *req.ContentType + } else { + updatedDocument.ContentType = s.contentTypeForDocType(updatedDocument.Type, req.Content) + } + } + + if err := s.documentRepo.UpdateApiDocument(&updatedDocument, updateContent); err != nil { + s.slogger.Error("Failed to update document", "artifactUUID", artifactUUID, "handle", handle, "error", err) + return err + } + + if err := s.auditRepo.Record("UPDATE", artifactUUID, "api_document", orgID, userID); err != nil { + s.slogger.Error("Failed to record audit entry for document update", "artifactUUID", artifactUUID, "error", err) + } return nil } @@ -234,8 +539,14 @@ func (s *APIDocumentService) ExtractOperationsFromSpec(specContent []byte) ([]ap return extractOperations(sd), nil } -// maxSpecFileNameLen is the DB column ceiling (file_name VARCHAR(255)). -const maxSpecFileNameLen = 255 +// DB column ceilings for api_documents. Mirror the schema so the service can +// reject over-length values with a 400 before the DB would reject them. +const ( + maxDocTypeLen = 20 + maxDocDisplayNameLen = 255 + maxDocFileNameLen = 255 + maxSpecFileNameLen = 255 +) // NormalizeSpecFileName strips the directory component from an uploaded filename // and caps the result to the DB column ceiling, preserving the extension and @@ -286,6 +597,24 @@ func (s *APIDocumentService) MergeOperations(existing *[]api.Operation, specOps return synced } +// DeleteAPIThumbnail removes the thumbnail document for an artifact. +func (s *APIDocumentService) DeleteAPIThumbnail(artifactUUID, orgID, userID string) error { + if artifactUUID == "" { + return apperror.ValidationFailed.New("artifact UUID is required") + } + if err := s.documentRepo.DeleteDocument(artifactUUID, constants.DocumentHandleThumbnail, orgID, constants.DocumentTypeThumbnail); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return apperror.NotFound.New() + } + s.slogger.Error("Failed to delete thumbnail", "artifactUUID", artifactUUID, "error", err) + return err + } + if err := s.auditRepo.Record("DELETE", artifactUUID, "api_thumbnail", orgID, userID); err != nil { + s.slogger.Error("Failed to record audit entry for thumbnail delete", "artifactUUID", artifactUUID, "error", err) + } + return nil +} + // GetSpecContentType determines the content type (JSON or YAML) for spec content. func (s *APIDocumentService) GetSpecContentType(specContent []byte) string { if utils.IsJSONBytes(specContent) { @@ -294,6 +623,27 @@ func (s *APIDocumentService) GetSpecContentType(specContent []byte) string { return "application/yaml" } +func (s *APIDocumentService) GetImageContentType(content []byte) string { + head := content + if len(head) > 512 { + head = head[:512] + } + return http.DetectContentType(head) +} + +// contentTypeForDocType chooses the stored MIME type for a document based on its type +func (s *APIDocumentService) contentTypeForDocType(docType string, content []byte) string { + switch docType { + case constants.DocumentTypeDefinition: + return s.GetSpecContentType(content) + case constants.DocumentTypeThumbnail: + return s.GetImageContentType(content) + default: + //Should be expanded if supporting other document types with specific content types. For now, default to markdown. + return "text/markdown; charset=utf-8" + } +} + // extractOperations builds api.Operation entries from the OpenAPI 3.x spec's paths. // Returns nil when paths are absent; the service layer creates a wildcard. func extractOperations(sd *utils.SpecDocument) []api.Operation { diff --git a/platform-api/internal/service/api_document_test.go b/platform-api/internal/service/api_document_test.go new file mode 100644 index 0000000000..7538aef262 --- /dev/null +++ b/platform-api/internal/service/api_document_test.go @@ -0,0 +1,1412 @@ +/* + * 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 service + +import ( + "database/sql" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "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/dto" + "github.com/wso2/api-platform/platform-api/internal/model" + "github.com/wso2/api-platform/platform-api/internal/repository" +) + +// --------------------------------------------------------------------------- +// Shared mocks +// --------------------------------------------------------------------------- + +type deleteApiDocCall struct { + artifactUUID, handle, orgUUID string +} + +type deleteDocCall struct { + artifactUUID, handle, orgUUID, docType string +} + +type upsertCall struct { + doc *model.Document +} + +// mockDocumentRepository embeds the real interface so missing methods panic +// on call rather than silently returning zero values. +type mockDocumentRepository struct { + repository.DocumentRepository + + // Return knobs — set per test. + getDocResult *model.Document + getDocErr error + handleExistsResult bool + handleExistsErr error + displayNameExistsResult bool + displayNameExistsErr error + createDocErr error + upsertDocErr error + updateApiDocErr error + deleteApiDocErr error + deleteDocErr error + listDocsResult []*model.Document + listDocsTotal int + listDocsErr error + + // Call tracking. + deleteApiDocCalls []deleteApiDocCall + deleteDocCalls []deleteDocCall + upsertCalls []upsertCall + createdDocs []*model.Document + updateApiDocCalls []*model.Document + updateApiDocContentFlags []bool + lastGetDocType string + lastDisplayNameExcludeHandle string +} + +func (m *mockDocumentRepository) GetDocument(artifactUUID, handle, orgUUID, docType string) (*model.Document, error) { + m.lastGetDocType = docType + return m.getDocResult, m.getDocErr +} + +func (m *mockDocumentRepository) DocumentHandleExistsForArtifact(artifactUUID, handle string) (bool, error) { + return m.handleExistsResult, m.handleExistsErr +} + +func (m *mockDocumentRepository) DocumentDisplayNameExistsForArtifact(artifactUUID, displayName, excludeHandle string) (bool, error) { + m.lastDisplayNameExcludeHandle = excludeHandle + return m.displayNameExistsResult, m.displayNameExistsErr +} + +func (m *mockDocumentRepository) CreateDocument(doc *model.Document) error { + if m.createDocErr != nil { + return m.createDocErr + } + m.createdDocs = append(m.createdDocs, doc) + return nil +} + +func (m *mockDocumentRepository) UpsertDocument(doc *model.Document) error { + if m.upsertDocErr != nil { + return m.upsertDocErr + } + m.upsertCalls = append(m.upsertCalls, upsertCall{doc: doc}) + return nil +} + +func (m *mockDocumentRepository) UpdateApiDocument(doc *model.Document, updateContent bool) error { + if m.updateApiDocErr != nil { + return m.updateApiDocErr + } + m.updateApiDocCalls = append(m.updateApiDocCalls, doc) + m.updateApiDocContentFlags = append(m.updateApiDocContentFlags, updateContent) + return nil +} + +func (m *mockDocumentRepository) DeleteApiDocument(artifactUUID, handle, orgUUID string) error { + m.deleteApiDocCalls = append(m.deleteApiDocCalls, deleteApiDocCall{artifactUUID, handle, orgUUID}) + return m.deleteApiDocErr +} + +func (m *mockDocumentRepository) DeleteDocument(artifactUUID, handle, orgUUID, docType string) error { + m.deleteDocCalls = append(m.deleteDocCalls, deleteDocCall{artifactUUID, handle, orgUUID, docType}) + return m.deleteDocErr +} + +func (m *mockDocumentRepository) ListDocumentsByArtifact(artifactUUID, orgUUID, docType string, limit, offset int) ([]*model.Document, int, error) { + return m.listDocsResult, m.listDocsTotal, m.listDocsErr +} + +type auditCall struct { + action, resourceUUID, resourceType, orgUUID, performedBy string +} + +type recordingAuditRepo struct { + calls []auditCall + err error +} + +func (r *recordingAuditRepo) Record(action, resourceUUID, resourceType, orgUUID, performedBy string) error { + r.calls = append(r.calls, auditCall{action, resourceUUID, resourceType, orgUUID, performedBy}) + return r.err +} + +// mockArtifactRepository is only used by tests that exercise ResolveArtifactUUID. +type mockArtifactRepository struct { + repository.ArtifactRepository + + metadataByHandleAndKind *model.APIMetadata + metadataErr error + lastHandle, lastKind string +} + +func (m *mockArtifactRepository) GetAPIMetadataByHandleAndKind(handle, kind, orgUUID string) (*model.APIMetadata, error) { + m.lastHandle, m.lastKind = handle, kind + return m.metadataByHandleAndKind, m.metadataErr +} + +func newTestDocumentService() (*APIDocumentService, *mockDocumentRepository, *recordingAuditRepo, *mockArtifactRepository) { + docRepo := &mockDocumentRepository{} + auditRepo := &recordingAuditRepo{} + artifactRepo := &mockArtifactRepository{} + svc := NewAPIDocumentService(docRepo, artifactRepo, auditRepo, slog.New(slog.NewTextHandler(discard{}, nil))) + return svc, docRepo, auditRepo, artifactRepo +} + +// discard swallows slog output so test output stays focused on failures. +type discard struct{} + +func (discard) Write(p []byte) (int, error) { return len(p), nil } + +// --------------------------------------------------------------------------- +// User-document CRUD (/apis/{apiType}/{apiId}/docs) +// --------------------------------------------------------------------------- + +// CreateApiDocument refuses reserved types (DEFINITION, THUMBNAIL) at the service layer so a user cannot bypass the dedicated endpoints by POSTing to /docs. +func TestAPIDocumentService_CreateApiDocument_RejectsReservedType(t *testing.T) { + cases := []struct { + name string + docType string + }{ + {"DEFINITION is rejected", constants.DocumentTypeDefinition}, + {"THUMBNAIL is rejected", constants.DocumentTypeThumbnail}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: tc.docType, + DisplayName: "attempt", + }, "org-1", "alice", "artifact-1") + + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("CreateApiDocument(type=%q) err = %v, want ValidationFailed", tc.docType, err) + } + if len(docRepo.createdDocs) != 0 { + t.Errorf("CreateDocument was called for a reserved type — must short-circuit before the repo") + } + }) + } +} + +// CreateApiDocument refuses a caller-supplied reserved handle (api-thumbnail / api-definition) so a user can't shadow the system singletons. +func TestAPIDocumentService_CreateApiDocument_RejectsReservedHandle(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeHowTo, + DisplayName: "Getting started", + Handle: constants.DocumentHandleThumbnail, + }, "org-1", "alice", "artifact-1") + + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("CreateApiDocument(handle=%q) err = %v, want ValidationFailed", + constants.DocumentHandleThumbnail, err) + } + if len(docRepo.createdDocs) != 0 { + t.Errorf("CreateDocument was called with a reserved handle — must short-circuit before the repo") + } +} + +// CreateApiDocument with type=OTHER refuses a free-text name that collides (case-insensitively) with a fixed/reserved type name. +func TestAPIDocumentService_CreateApiDocument_ForbiddenOtherNameRejected(t *testing.T) { + // Pick whichever forbidden name exists so this test survives additions. + var forbiddenName string + for name := range constants.ForbiddenOtherTypeNames { + forbiddenName = strings.ToLower(name) + break + } + if forbiddenName == "" { + t.Skip("ForbiddenOtherTypeNames is empty — nothing to test") + } + + svc, _, _, _ := newTestDocumentService() + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeOther, + OtherTypeName: forbiddenName, + DisplayName: "benign display", + }, "org-1", "alice", "artifact-1") + + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("CreateApiDocument(OTHER, otherTypeName=%q) err = %v, want ValidationFailed", + forbiddenName, err) + } +} + +// CreateApiDocument allows an otherTypeName that starts with DOC_ — custom doc types are not restricted by prefix. +func TestAPIDocumentService_CreateApiDocument_AllowsDOCPrefixedOtherTypeName(t *testing.T) { + cases := []string{"DOC_HowTo", "doc_samples", "DOC_Other", "DOC_Custom"} + for _, name := range cases { + svc, docRepo, _, _ := newTestDocumentService() + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeOther, + OtherTypeName: name, + DisplayName: "fine display", + }, "org-1", "alice", "artifact-1") + if err != nil { + t.Errorf("CreateApiDocument(OTHER, otherTypeName=%q) err = %v, want nil (DOC_ prefix is allowed)", name, err) + } + if len(docRepo.createdDocs) != 1 { + t.Errorf("CreateApiDocument(OTHER, otherTypeName=%q): got %d created docs, want 1", name, len(docRepo.createdDocs)) + } + } +} + +// UpdateApiDocument allows an otherTypeName that starts with DOC_ — custom doc types are not restricted by prefix. +func TestAPIDocumentService_UpdateApiDocument_AllowsDOCPrefixedOtherTypeName(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + ID: "doc-1", ArtifactUUID: "artifact-1", OrganizationUUID: "org-1", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "guide", DisplayName: "Guide", + } + newType := constants.DocumentTypeOther + docPrefixed := "DOC_Custom" + err := svc.UpdateApiDocument(&dto.UpdateAPIDocumentRequest{ + Type: &newType, + OtherTypeName: docPrefixed, + }, "org-1", "alice", "artifact-1", "doc-1") + if err != nil { + t.Errorf("UpdateApiDocument(OTHER, otherTypeName=%q) err = %v, want nil (DOC_ prefix is allowed)", docPrefixed, err) + } + if len(docRepo.updateApiDocCalls) != 1 { + t.Errorf("UpdateApiDocument(OTHER, otherTypeName=%q): got %d update calls, want 1", docPrefixed, len(docRepo.updateApiDocCalls)) + } +} + +// CreateApiDocument allows a duplicate display name within the same artifact — uniqueness is not enforced. +func TestAPIDocumentService_CreateApiDocument_DuplicateDisplayNameAllowed(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeHowTo, + DisplayName: "Getting started", + }, "org-1", "alice", "artifact-1") + + if err != nil { + t.Fatalf("CreateApiDocument with duplicate displayName err = %v, want nil (duplicate display names are allowed)", err) + } + if len(docRepo.createdDocs) != 1 { + t.Errorf("CreateDocument not called: got %d created docs, want 1", len(docRepo.createdDocs)) + } +} + +// CreateApiDocument refuses a caller-supplied handle that already exists on the same artifact (DB unique index enforced service-side first). +func TestAPIDocumentService_CreateApiDocument_DuplicateHandleRejected(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.handleExistsResult = true + + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeHowTo, + DisplayName: "Different name", + Handle: "overview", + }, "org-1", "alice", "artifact-1") + + if err == nil || !apperror.Conflict.Is(err) { + t.Fatalf("CreateApiDocument duplicate handle err = %v, want Conflict", err) + } + if len(docRepo.createdDocs) != 0 { + t.Errorf("CreateDocument called despite duplicate handle") + } +} + +// ResolveArtifactUUID collapses an unknown apiType to NotFound +func TestAPIDocumentService_ResolveArtifactUUID_UnknownKindMapsToNotFound(t *testing.T) { + svc, _, _, artifactRepo := newTestDocumentService() + artifactRepo.metadataErr = repository.ErrUnknownArtifactKind + + _, err := svc.ResolveArtifactUUID("not-a-real-kind", "whatever", "org-1") + if err == nil || !apperror.NotFound.Is(err) { + t.Errorf("ResolveArtifactUUID(unknown kind) err = %v, want NotFound", err) + } +} + +// ResolveArtifactUUID returns NotFound when the handle doesn't match any row for the given kind. +func TestAPIDocumentService_ResolveArtifactUUID_UnknownHandleMapsToNotFound(t *testing.T) { + svc, _, _, artifactRepo := newTestDocumentService() + artifactRepo.metadataByHandleAndKind = nil + artifactRepo.metadataErr = nil + + _, err := svc.ResolveArtifactUUID(constants.RestApi, "no-such-api", "org-1") + if err == nil || !apperror.NotFound.Is(err) { + t.Errorf("ResolveArtifactUUID(unknown handle) err = %v, want NotFound", err) + } +} + +// ResolveArtifactUUID returns NotFound when either apiType or apiId is empty, rather than falling through to the repo. +func TestAPIDocumentService_ResolveArtifactUUID_EmptyInputsRejected(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + + for _, tc := range []struct{ apiType, apiID string }{ + {"", "orders-api"}, + {"rest-api", ""}, + {"", ""}, + } { + _, err := svc.ResolveArtifactUUID(tc.apiType, tc.apiID, "org-1") + if err == nil || !apperror.NotFound.Is(err) { + t.Errorf("ResolveArtifactUUID(%q, %q) err = %v, want NotFound", tc.apiType, tc.apiID, err) + } + } +} + +// UpdateApiDocument passes updateContent=false to the repo when the request has no new bytes, so a metadata-only PUT never overwrites the stored body. +func TestAPIDocumentService_UpdateApiDocument_PreservesContentWhenRequestOmitsIt(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + ID: "doc-1", + ArtifactUUID: "artifact-1", + OrganizationUUID: "org-1", + Type: constants.DocumentTypeHowTo, + Handle: "overview", + DisplayName: "Overview", + ContentType: "text/markdown; charset=utf-8", + Content: []byte("# Overview"), + } + + newName := "Overview v2" + err := svc.UpdateApiDocument(&dto.UpdateAPIDocumentRequest{ + DisplayName: &newName, + }, "org-1", "alice", "artifact-1", "overview") + if err != nil { + t.Fatalf("UpdateApiDocument err = %v, want nil", err) + } + + if len(docRepo.updateApiDocCalls) != 1 { + t.Fatalf("expected one UpdateApiDocument call, got %d", len(docRepo.updateApiDocCalls)) + } + if docRepo.updateApiDocContentFlags[0] != false { + t.Errorf("UpdateApiDocument updateContent flag = true, want false — a metadata-only PUT must not overwrite stored bytes") + } + if docRepo.updateApiDocCalls[0].DisplayName != newName { + t.Errorf("UpdateApiDocument displayName = %q, want %q", + docRepo.updateApiDocCalls[0].DisplayName, newName) + } +} + +// DeleteApiDocument refuses a reserved handle at the service layer so a user-facing DELETE can't reach a system-managed row even by name. +func TestAPIDocumentService_DeleteApiDocument_RejectsReservedHandle(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + + err := svc.DeleteApiDocument("artifact-1", constants.DocumentHandleThumbnail, "org-1", "alice") + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("DeleteApiDocument(handle=%q) err = %v, want ValidationFailed", + constants.DocumentHandleThumbnail, err) + } + if len(docRepo.deleteApiDocCalls) != 0 { + t.Errorf("DeleteApiDocument reached the repo for a reserved handle") + } +} + +// DeleteApiDocument maps a nil GetDocument result to NotFound rather than leaking a 500 or a silent 204. +func TestAPIDocumentService_DeleteApiDocument_NotFoundMapsToAppError(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = nil + + err := svc.DeleteApiDocument("artifact-1", "overview", "org-1", "alice") + if err == nil || !apperror.NotFound.Is(err) { + t.Errorf("DeleteApiDocument(unknown handle) err = %v, want NotFound", err) + } +} + +// DeleteApiDocument on a user doc calls the repo's user-facing delete path and records an audit event with the generic api_document resource label. +func TestAPIDocumentService_DeleteApiDocument_SuccessHits(t *testing.T) { + svc, docRepo, auditRepo, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + ArtifactUUID: "artifact-1", + OrganizationUUID: "org-1", + Handle: "overview", + Type: constants.DocumentTypeHowTo, + DisplayName: "Overview", + } + + if err := svc.DeleteApiDocument("artifact-1", "overview", "org-1", "alice"); err != nil { + t.Fatalf("DeleteApiDocument err = %v, want nil", err) + } + if len(docRepo.deleteApiDocCalls) != 1 || docRepo.deleteApiDocCalls[0].handle != "overview" { + t.Fatalf("DeleteApiDocument calls = %+v, want one with handle=overview", docRepo.deleteApiDocCalls) + } + if len(auditRepo.calls) != 1 || auditRepo.calls[0].resourceType != "api_document" { + t.Errorf("audit resourceType = %+v, want {api_document}", auditRepo.calls) + } +} + +// --------------------------------------------------------------------------- +// OpenAPI definition (/openapi) — validate / extract / normalise / merge +// --------------------------------------------------------------------------- + +const minimalOpenAPISpec = `openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK + post: + summary: Create pet + responses: + '201': + description: Created + /pets/{petId}: + get: + summary: Get pet + parameters: + - name: petId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK +` + +// ValidateOpenAPISpec reports IsValid=true for a well-formed OpenAPI 3.0 document. +func TestAPIDocumentService_ValidateOpenAPISpec_AcceptsValidSpec(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + result := svc.ValidateOpenAPISpec([]byte(minimalOpenAPISpec)) + if !result.IsValid { + t.Errorf("ValidateOpenAPISpec on valid spec: IsValid=false, errors=%+v", result.Errors) + } +} + +// ValidateOpenAPISpec reports IsValid=false with at least one error for a document that doesn't even parse as a spec. +func TestAPIDocumentService_ValidateOpenAPISpec_RejectsGarbage(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + result := svc.ValidateOpenAPISpec([]byte("not-an-openapi-spec")) + if result.IsValid { + t.Fatal("ValidateOpenAPISpec on garbage: IsValid=true, want false") + } + if len(result.Errors) == 0 { + t.Error("ValidateOpenAPISpec on garbage returned no error details") + } +} + +// ValidateOpenAPISpec reports IsValid=false for a Swagger 2.0 document — the service only accepts OpenAPI 3.x. +func TestAPIDocumentService_ValidateOpenAPISpec_RejectsSwagger2(t *testing.T) { + swagger2 := `swagger: "2.0" +info: + title: Legacy + version: 1.0.0 +paths: {} +` + svc, _, _, _ := newTestDocumentService() + result := svc.ValidateOpenAPISpec([]byte(swagger2)) + if result.IsValid { + t.Error("ValidateOpenAPISpec on Swagger 2.0: IsValid=true, want false") + } +} + +// ExtractOperationsFromSpec returns one Operation per (method, path) in the spec with the method normalised to uppercase. +func TestAPIDocumentService_ExtractOperationsFromSpec_ReturnsOnePerMethodPath(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + ops, err := svc.ExtractOperationsFromSpec([]byte(minimalOpenAPISpec)) + if err != nil { + t.Fatalf("ExtractOperationsFromSpec err = %v, want nil", err) + } + if len(ops) != 3 { + t.Fatalf("got %d ops, want 3 (GET /pets, POST /pets, GET /pets/{petId})", len(ops)) + } + // Verify (method, path) set equality — order isn't guaranteed by the YAML parser. + seen := make(map[string]bool, len(ops)) + for _, op := range ops { + if string(op.Request.Method) != strings.ToUpper(string(op.Request.Method)) { + t.Errorf("op method = %q is not uppercase", op.Request.Method) + } + seen[string(op.Request.Method)+" "+op.Request.Path] = true + } + for _, want := range []string{"GET /pets", "POST /pets", "GET /pets/{petId}"} { + if !seen[want] { + t.Errorf("expected op %q not found; got %v", want, seen) + } + } +} + +// ExtractOperationsFromSpec returns an error wrapped as ValidationFailed when the spec is not loadable. +func TestAPIDocumentService_ExtractOperationsFromSpec_RejectsInvalidSpec(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + _, err := svc.ExtractOperationsFromSpec([]byte("::: not yaml :::")) + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("ExtractOperationsFromSpec(garbage) err = %v, want ValidationFailed", err) + } +} + +// ExtractOperationsFromSpec rejects a spec with no operations — the validator requires at least one, so an empty paths map comes back as ValidationFailed rather than silently returning nil. +func TestAPIDocumentService_ExtractOperationsFromSpec_RejectsEmptyPaths(t *testing.T) { + emptyPaths := `openapi: 3.0.0 +info: + title: Empty + version: 1.0.0 +paths: {} +` + svc, _, _, _ := newTestDocumentService() + _, err := svc.ExtractOperationsFromSpec([]byte(emptyPaths)) + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("ExtractOperationsFromSpec(empty paths) err = %v, want ValidationFailed", err) + } +} + +// NormalizeSpecFileName strips the directory component so the stored filename never contains a user-supplied path (file-access.md directive 2). +func TestAPIDocumentService_NormalizeSpecFileName_StripsDirectory(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + got := svc.NormalizeSpecFileName("../../etc/passwd/openapi.yaml") + if got != "openapi.yaml" { + t.Errorf("NormalizeSpecFileName = %q, want %q", got, "openapi.yaml") + } +} + +// NormalizeSpecFileName caps the result at the DB column ceiling (255) while preserving the extension, so a long name never explodes the INSERT. +func TestAPIDocumentService_NormalizeSpecFileName_CapsLengthPreservingExtension(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + long := strings.Repeat("a", 400) + ".yaml" + got := svc.NormalizeSpecFileName(long) + if len(got) > 255 { + t.Errorf("NormalizeSpecFileName returned a %d-byte name, want <=255", len(got)) + } + if !strings.HasSuffix(got, ".yaml") { + t.Errorf("NormalizeSpecFileName dropped the extension: %q", got) + } +} + +// MergeOperations keeps the stored per-operation policies on an op whose (method, path) also appears in the new spec, so a spec update never silently drops attached policies. +func TestAPIDocumentService_MergeOperations_PreservesPoliciesOnKnownOp(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + + storedPolicies := []api.Policy{{Name: "rate-limit", Version: "v1"}} + existing := []api.Operation{ + { + Request: api.OperationRequest{ + Method: api.OperationRequestMethod("GET"), + Path: "/pets", + Policies: &storedPolicies, + }, + }, + } + fromSpec := []api.Operation{ + {Request: api.OperationRequest{Method: api.OperationRequestMethod("GET"), Path: "/pets"}}, + } + + merged := svc.MergeOperations(&existing, fromSpec) + if len(merged) != 1 { + t.Fatalf("merged length = %d, want 1", len(merged)) + } + if merged[0].Request.Policies == nil || len(*merged[0].Request.Policies) != 1 { + t.Fatalf("expected stored policy to survive merge, got %+v", merged[0].Request.Policies) + } + if (*merged[0].Request.Policies)[0].Name != "rate-limit" { + t.Errorf("merged policy name = %q, want rate-limit", (*merged[0].Request.Policies)[0].Name) + } +} + +// MergeOperations drops an op present in the stored list but absent in the new spec, so removing an endpoint actually removes it. +func TestAPIDocumentService_MergeOperations_DropsRemovedOp(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + + existing := []api.Operation{ + {Request: api.OperationRequest{Method: "GET", Path: "/pets"}}, + {Request: api.OperationRequest{Method: "DELETE", Path: "/pets/{petId}"}}, + } + fromSpec := []api.Operation{ + {Request: api.OperationRequest{Method: "GET", Path: "/pets"}}, + } + + merged := svc.MergeOperations(&existing, fromSpec) + if len(merged) != 1 { + t.Fatalf("merged length = %d, want 1 (DELETE should be gone)", len(merged)) + } + if merged[0].Request.Method != "GET" || merged[0].Request.Path != "/pets" { + t.Errorf("merged = %+v, want only GET /pets", merged[0].Request) + } +} + +// MergeOperations adds a new op that only appears in the spec, with no inherited policies, so adding an endpoint takes effect on next write. +func TestAPIDocumentService_MergeOperations_AddsNewOp(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + + existing := []api.Operation{ + {Request: api.OperationRequest{Method: "GET", Path: "/pets"}}, + } + fromSpec := []api.Operation{ + {Request: api.OperationRequest{Method: "GET", Path: "/pets"}}, + {Request: api.OperationRequest{Method: "POST", Path: "/pets"}}, + } + + merged := svc.MergeOperations(&existing, fromSpec) + if len(merged) != 2 { + t.Fatalf("merged length = %d, want 2", len(merged)) + } + var post *api.Operation + for i := range merged { + if merged[i].Request.Method == "POST" { + post = &merged[i] + } + } + if post == nil { + t.Fatal("POST /pets missing from merged ops") + } + if post.Request.Policies != nil && len(*post.Request.Policies) != 0 { + t.Errorf("new op carried inherited policies = %+v, want none", post.Request.Policies) + } +} + +// ExtractAndMergeOperations composes extract + merge: a valid spec plus an existing op list that carried policies yields merged ops with policies preserved on known paths. +func TestAPIDocumentService_ExtractAndMergeOperations_PreservesPoliciesOnKnownPaths(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + + storedPolicies := []api.Policy{{Name: "rate-limit", Version: "v1"}} + existing := []api.Operation{ + { + Request: api.OperationRequest{ + Method: "GET", + Path: "/pets", + Policies: &storedPolicies, + }, + }, + } + + merged, err := svc.ExtractAndMergeOperations([]byte(minimalOpenAPISpec), &existing) + if err != nil { + t.Fatalf("ExtractAndMergeOperations err = %v, want nil", err) + } + // Three ops in the spec; the GET /pets one must still carry the stored policy. + var found bool + for _, op := range merged { + if op.Request.Method == "GET" && op.Request.Path == "/pets" { + found = true + if op.Request.Policies == nil || len(*op.Request.Policies) == 0 { + t.Error("GET /pets lost its stored policies through ExtractAndMergeOperations") + } + } + } + if !found { + t.Error("GET /pets missing from merged output") + } +} + +// --------------------------------------------------------------------------- +// Pure helpers — NormalizeAPIDocumentType, decodeStoredDocType, +// modelToAPIMetadata, GetSpecContentType, GetImageContentType +// --------------------------------------------------------------------------- +// +// These helpers are reached only through the handler today, so the file-local +// coverage of the service package misses them entirely. Driving them directly +// gets them attributed to the service file in Codecov's per-package rollup. + +// NormalizeAPIDocumentType accepts the canonical casing, lowercase, and surrounding whitespace, and rejects any other input. +func TestAPIDocumentService_NormalizeAPIDocumentType(t *testing.T) { + cases := []struct { + input, want string + ok bool + }{ + {"HowTo", constants.DocumentTypeHowTo, true}, + {"howto", constants.DocumentTypeHowTo, true}, + {" HowTo ", constants.DocumentTypeHowTo, true}, + {"HOWTO", constants.DocumentTypeHowTo, true}, + {"Samples", constants.DocumentTypeSamples, true}, + {"SupportForum", constants.DocumentTypeSupportForum, true}, + {"PublicForum", constants.DocumentTypePublicForum, true}, + {"Other", constants.DocumentTypeOther, true}, + // A caller-supplied custom type (used with Other.otherTypeName) is not + // one of the known canonical values and must not round-trip here. + {"FAQ", "", false}, + {"", "", false}, + {" ", "", false}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + got, ok := NormalizeAPIDocumentType(tc.input) + if ok != tc.ok || got != tc.want { + t.Errorf("NormalizeAPIDocumentType(%q) = (%q, %v), want (%q, %v)", tc.input, got, ok, tc.want, tc.ok) + } + }) + } +} + +// decodeStoredDocType strips the DOC_ storage prefix and leaves a bare custom +// type (which is already stored without the prefix) alone. +func TestAPIDocumentService_decodeStoredDocType(t *testing.T) { + cases := []struct { + stored, want string + }{ + {constants.DocumentTypePrefix + constants.DocumentTypeHowTo, constants.DocumentTypeHowTo}, + {constants.DocumentTypePrefix + "FAQ", "FAQ"}, + {constants.DocumentTypePrefix + constants.DocumentTypeOther, constants.DocumentTypeOther}, + // A legacy row stored without the prefix must be returned verbatim — + // a double-decode here would turn "HowTo" into something other than "HowTo". + {"HowTo", "HowTo"}, + {"", ""}, + } + for _, tc := range cases { + t.Run(tc.stored, func(t *testing.T) { + if got := decodeStoredDocType(tc.stored); got != tc.want { + t.Errorf("decodeStoredDocType(%q) = %q, want %q", tc.stored, got, tc.want) + } + }) + } +} + +// modelToAPIMetadata emits optional fields only when their stored value is +// non-zero, and decodes the DOC_ prefix on type. +func TestAPIDocumentService_modelToAPIMetadata(t *testing.T) { + now := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + + t.Run("populated fields round-trip, type is decoded", func(t *testing.T) { + got := modelToAPIMetadata(&model.Document{ + Handle: "guide", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + DisplayName: "Guide", + FileName: "guide.md", + ContentType: "text/markdown; charset=utf-8", + CreatedBy: "alice", + CreatedAt: now, + UpdatedBy: "bob", + UpdatedAt: now, + }) + if got.Id != "guide" || got.DisplayName != "Guide" { + t.Errorf("id/displayName = %q/%q", got.Id, got.DisplayName) + } + if got.Type != constants.DocumentTypeHowTo { + t.Errorf("type = %q, want %q (DOC_ prefix must be decoded)", got.Type, constants.DocumentTypeHowTo) + } + if got.FileName == nil || *got.FileName != "guide.md" { + t.Errorf("fileName = %v, want guide.md", got.FileName) + } + if got.ContentType == nil || got.CreatedBy == nil || got.UpdatedBy == nil { + t.Errorf("populated metadata should not nil out optional strings: %+v", got) + } + if got.CreatedAt == nil || got.UpdatedAt == nil { + t.Errorf("populated metadata should not nil out optional times: %+v", got) + } + }) + + t.Run("zero-value fields are omitted", func(t *testing.T) { + got := modelToAPIMetadata(&model.Document{ + Handle: "guide", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + DisplayName: "Guide", + // FileName, ContentType, CreatedBy, UpdatedBy are empty; timestamps are zero. + }) + if got.FileName != nil || got.ContentType != nil { + t.Errorf("empty optional strings should be nil: %+v", got) + } + if got.CreatedBy != nil || got.UpdatedBy != nil { + t.Errorf("empty optional actor strings should be nil: %+v", got) + } + if got.CreatedAt != nil || got.UpdatedAt != nil { + t.Errorf("zero timestamps should be nil: %+v", got) + } + }) +} + +// GetSpecContentType returns application/json for a JSON body and +// application/yaml otherwise, including when the sniff is ambiguous. +func TestAPIDocumentService_GetSpecContentType(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + cases := []struct { + name string + content []byte + wantType string + }{ + {"JSON object", []byte(`{"openapi":"3.0.0"}`), "application/json"}, + {"JSON with leading whitespace", []byte(" {\"openapi\":\"3.0.0\"}"), "application/json"}, + {"YAML with leading whitespace", []byte("\n openapi: 3.0.0\n"), "application/yaml"}, + {"empty body falls back to YAML", []byte{}, "application/yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := svc.GetSpecContentType(tc.content); got != tc.wantType { + t.Errorf("GetSpecContentType = %q, want %q", got, tc.wantType) + } + }) + } +} + +// GetImageContentType uses magic bytes, not the first N bytes, so an image +// smaller than the 512-byte sniff buffer is still classified correctly. +func TestAPIDocumentService_GetImageContentType(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 24)...) + jpeg := append([]byte("\xff\xd8\xff\xe0"), make([]byte, 24)...) + gif := append([]byte("GIF89a"), make([]byte, 24)...) + plain := []byte("hello world") + oversized := append(append([]byte{}, png...), make([]byte, 2048)...) + + cases := []struct { + name string + content []byte + wantType string + }{ + {"PNG", png, "image/png"}, + {"JPEG", jpeg, "image/jpeg"}, + {"GIF (not on the thumbnail allowlist but still sniffed)", gif, "image/gif"}, + {"plain text", plain, "text/plain; charset=utf-8"}, + {"larger than 512-byte sniff window", oversized, "image/png"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := svc.GetImageContentType(tc.content); got != tc.wantType { + t.Errorf("GetImageContentType = %q, want %q", got, tc.wantType) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Reads — GetAllApiDocuments, GetDocument, GetDocumentWithContent +// --------------------------------------------------------------------------- + +func TestAPIDocumentService_GetAllApiDocuments_Success(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.listDocsResult = []*model.Document{ + {Handle: "guide", Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, DisplayName: "Guide"}, + // A row stored with a custom DOC_FAQ type is decoded back to the bare FAQ. + {Handle: "faq", Type: constants.DocumentTypePrefix + "FAQ", DisplayName: "FAQ"}, + } + docRepo.listDocsTotal = 7 + + items, total, err := svc.GetAllApiDocuments("artifact-1", "org-1", "", 20, 0) + if err != nil { + t.Fatalf("GetAllApiDocuments err = %v", err) + } + if total != 7 || len(items) != 2 { + t.Fatalf("len/total = %d/%d, want 2/7", len(items), total) + } + if items[0].Type != constants.DocumentTypeHowTo || items[1].Type != "FAQ" { + t.Errorf("decoded types = %q, %q", items[0].Type, items[1].Type) + } +} + +// A blank artifactUUID is a 400 before any repository call — the handler +// should always resolve the artifact first, but if that invariant is ever +// broken the service refuses to query against an empty key. +func TestAPIDocumentService_GetAllApiDocuments_RequiresArtifactUUID(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + _, _, err := svc.GetAllApiDocuments("", "org-1", "", 20, 0) + if err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("err = %v, want ValidationFailed", err) + } + if docRepo.listDocsResult != nil || docRepo.listDocsTotal != 0 { + // (Belt-and-braces: the mock's defaults are already zero. This proves + // the service didn't change them by dispatching to the repo.) + t.Errorf("the repo must not be consulted when the request is invalid") + } +} + +func TestAPIDocumentService_GetAllApiDocuments_RepoErrorIsSurfaced(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.listDocsErr = errors.New("db down") + if _, _, err := svc.GetAllApiDocuments("artifact-1", "org-1", "", 20, 0); err == nil { + t.Fatalf("err = nil, want the repo error to surface") + } +} + +func TestAPIDocumentService_GetDocument_Success(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + Handle: "guide", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + DisplayName: "Guide", + } + got, err := svc.GetDocument("artifact-1", "guide", "org-1") + if err != nil { + t.Fatalf("GetDocument err = %v", err) + } + if got.Id != "guide" || got.Type != constants.DocumentTypeHowTo { + t.Errorf("metadata = %+v", got) + } +} + +func TestAPIDocumentService_GetDocument_NotFound(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + // All repo defaults — getDocResult is nil, getDocErr is nil. + if _, err := svc.GetDocument("artifact-1", "missing", "org-1"); err == nil || !apperror.NotFound.Is(err) { + t.Fatalf("err = %v, want NotFound", err) + } +} + +func TestAPIDocumentService_GetDocument_RequiresArtifactAndHandle(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + if _, err := svc.GetDocument("", "guide", "org-1"); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty artifact UUID: err = %v, want ValidationFailed", err) + } + if _, err := svc.GetDocument("artifact-1", "", "org-1"); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty handle: err = %v, want ValidationFailed", err) + } +} + +func TestAPIDocumentService_GetDocument_RepoErrorIsSurfaced(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocErr = errors.New("db down") + if _, err := svc.GetDocument("artifact-1", "guide", "org-1"); err == nil { + t.Fatal("err = nil, want the repo error to surface") + } +} + +// GetDocumentWithContent returns both metadata and raw bytes, keyed on the +// optional docType to enforce the reserved-row contract at read time. +func TestAPIDocumentService_GetDocumentWithContent_Success(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + Handle: "guide", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + DisplayName: "Guide", + ContentType: "text/markdown; charset=utf-8", + Content: []byte("# Hello"), + } + md, content, err := svc.GetDocumentWithContent("artifact-1", "guide", "org-1", "") + if err != nil { + t.Fatalf("GetDocumentWithContent err = %v", err) + } + if string(content) != "# Hello" { + t.Errorf("content = %q, want %q", content, "# Hello") + } + if md == nil || md.Id != "guide" { + t.Errorf("metadata = %+v", md) + } +} + +func TestAPIDocumentService_GetDocumentWithContent_NotFound(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + if _, _, err := svc.GetDocumentWithContent("artifact-1", "missing", "org-1", ""); err == nil || !apperror.NotFound.Is(err) { + t.Fatalf("err = %v, want NotFound", err) + } +} + +func TestAPIDocumentService_GetDocumentWithContent_RequiresArtifactAndHandle(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + if _, _, err := svc.GetDocumentWithContent("", "guide", "org-1", ""); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty artifact: err = %v, want ValidationFailed", err) + } + if _, _, err := svc.GetDocumentWithContent("artifact-1", "", "org-1", ""); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty handle: err = %v, want ValidationFailed", err) + } +} + +// The docType argument is forwarded to the repository so the thumbnail GET +// path (which pins THUMBNAIL) cannot accidentally return a user-document row +// stored under the reserved handle. +func TestAPIDocumentService_GetDocumentWithContent_ForwardsDocType(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{Handle: constants.DocumentHandleThumbnail, Type: constants.DocumentTypeThumbnail} + _, _, _ = svc.GetDocumentWithContent("artifact-1", constants.DocumentHandleThumbnail, "org-1", constants.DocumentTypeThumbnail) + if docRepo.lastGetDocType != constants.DocumentTypeThumbnail { + t.Errorf("repo was queried with docType = %q, want %q", docRepo.lastGetDocType, constants.DocumentTypeThumbnail) + } +} + +// --------------------------------------------------------------------------- +// UpsertDocument — the thumbnail write path +// --------------------------------------------------------------------------- + +func TestAPIDocumentService_UpsertDocument_CreateRecordsCreateAudit(t *testing.T) { + svc, docRepo, auditRepo, _ := newTestDocumentService() + // getDocResult is nil → the service treats this as an insert. + + req := &dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + FileName: "logo.png", + Content: []byte("\x89PNG\r\n\x1a\n"), + } + if err := svc.UpsertDocument(req, "org-1", "alice", "artifact-1"); err != nil { + t.Fatalf("UpsertDocument err = %v", err) + } + if len(docRepo.upsertCalls) != 1 { + t.Fatalf("upsert calls = %d, want 1", len(docRepo.upsertCalls)) + } + stored := docRepo.upsertCalls[0].doc + if stored.CreatedBy != "alice" || stored.UpdatedBy != "alice" { + t.Errorf("createdBy/updatedBy = %q/%q, want alice/alice on create", stored.CreatedBy, stored.UpdatedBy) + } + if len(auditRepo.calls) != 1 || auditRepo.calls[0].action != "CREATE" { + t.Errorf("audit = %+v, want one CREATE entry", auditRepo.calls) + } +} + +// A thumbnail already present on this API is an UPDATE, not a CREATE — +// audit must reflect it, and CreatedBy must not be overwritten. +func TestAPIDocumentService_UpsertDocument_ExistingRowIsUpdateAudit(t *testing.T) { + svc, docRepo, auditRepo, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + Handle: constants.DocumentHandleThumbnail, + CreatedBy: "original-uploader", + } + + req := &dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeThumbnail, + Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + FileName: "new.png", + Content: []byte("\x89PNG\r\n\x1a\n"), + } + if err := svc.UpsertDocument(req, "org-1", "bob", "artifact-1"); err != nil { + t.Fatalf("UpsertDocument err = %v", err) + } + stored := docRepo.upsertCalls[0].doc + if stored.CreatedBy == "bob" { + t.Errorf("CreatedBy was overwritten to the updater on an UPDATE: %+v", stored) + } + if stored.UpdatedBy != "bob" { + t.Errorf("UpdatedBy = %q, want bob", stored.UpdatedBy) + } + if len(auditRepo.calls) != 1 || auditRepo.calls[0].action != "UPDATE" { + t.Errorf("audit = %+v, want one UPDATE entry", auditRepo.calls) + } +} + +func TestAPIDocumentService_UpsertDocument_Validation(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + if err := svc.UpsertDocument(nil, "org-1", "alice", "artifact-1"); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("nil request: err = %v, want ValidationFailed", err) + } + if err := svc.UpsertDocument(&dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeThumbnail}, "org-1", "alice", ""); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty artifact UUID: err = %v, want ValidationFailed", err) + } +} + +func TestAPIDocumentService_UpsertDocument_RepoErrorIsSurfaced(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.upsertDocErr = errors.New("disk full") + err := svc.UpsertDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeThumbnail, Handle: constants.DocumentHandleThumbnail, + DisplayName: constants.DocumentDisplayNameThumbnail, + Content: []byte("\x89PNG\r\n\x1a\n"), + }, "org-1", "alice", "artifact-1") + if err == nil { + t.Fatal("err = nil, want the repo error to surface") + } +} + +// --------------------------------------------------------------------------- +// DeleteAPIThumbnail +// --------------------------------------------------------------------------- + +func TestAPIDocumentService_DeleteAPIThumbnail_Success(t *testing.T) { + svc, docRepo, auditRepo, _ := newTestDocumentService() + if err := svc.DeleteAPIThumbnail("artifact-1", "org-1", "alice"); err != nil { + t.Fatalf("DeleteAPIThumbnail err = %v", err) + } + if len(docRepo.deleteDocCalls) != 1 { + t.Fatalf("delete calls = %d, want 1", len(docRepo.deleteDocCalls)) + } + call := docRepo.deleteDocCalls[0] + // Both the handle and the type are pinned to the thumbnail singleton — a + // future change that routes this through DeleteApiDocument instead would + // stop filtering by type and could delete a user-document stored under + // the same handle. + if call.handle != constants.DocumentHandleThumbnail || call.docType != constants.DocumentTypeThumbnail { + t.Errorf("delete targeted handle=%q type=%q, want the thumbnail singleton", call.handle, call.docType) + } + if len(auditRepo.calls) != 1 || auditRepo.calls[0].action != "DELETE" || + auditRepo.calls[0].resourceType != "api_thumbnail" { + t.Errorf("audit = %+v, want DELETE api_thumbnail", auditRepo.calls) + } +} + +// A missing thumbnail must surface as NotFound rather than a generic 500 so +// the handler can map it to a 404. +func TestAPIDocumentService_DeleteAPIThumbnail_NotFound(t *testing.T) { + svc, docRepo, auditRepo, _ := newTestDocumentService() + docRepo.deleteDocErr = sql.ErrNoRows + err := svc.DeleteAPIThumbnail("artifact-1", "org-1", "alice") + if err == nil || !apperror.NotFound.Is(err) { + t.Fatalf("err = %v, want NotFound", err) + } + if len(auditRepo.calls) != 0 { + t.Errorf("audit entries recorded for a no-op delete: %+v", auditRepo.calls) + } +} + +func TestAPIDocumentService_DeleteAPIThumbnail_RequiresArtifactUUID(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + if err := svc.DeleteAPIThumbnail("", "org-1", "alice"); err == nil || !apperror.ValidationFailed.Is(err) { + t.Fatalf("err = %v, want ValidationFailed", err) + } + if len(docRepo.deleteDocCalls) != 0 { + t.Errorf("repo was called despite an invalid request") + } +} + +func TestAPIDocumentService_DeleteAPIThumbnail_RepoErrorIsSurfaced(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.deleteDocErr = errors.New("db down") + err := svc.DeleteAPIThumbnail("artifact-1", "org-1", "alice") + if err == nil { + t.Fatal("err = nil, want the repo error to surface") + } + // Avoid leaking the raw repo error — the handler collapses non-apperror + // values into a sterile 500 at the boundary. Here we only need to confirm + // *something* non-nil was returned so Codecov records the branch. + _ = strings.Contains(err.Error(), "") +} + +// --------------------------------------------------------------------------- +// Branch gaps the integration-level tests don't drive +// --------------------------------------------------------------------------- + +// CreateDocument generates the handle from displayName when no `id` is given; +// a user-supplied handle short-circuits the generator. Both branches of +// GenerateHandle's existence callback are exercised: the reserved-handle +// predicate and the per-artifact-exists predicate. +func TestAPIDocumentService_CreateDocument_GeneratesHandleFromDisplayName(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + // First candidate "release-notes" (slug of "Release Notes") is not reserved + // and does not exist for this artifact, so the generator accepts it. + req := &dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + DisplayName: "Release Notes", + Content: []byte("# body"), + } + handle, err := svc.CreateDocument(req, "org", "alice", "artifact-1") + if err != nil { + t.Fatalf("CreateDocument err = %v", err) + } + if handle != "release-notes" { + t.Errorf("generated handle = %q, want release-notes", handle) + } + if len(docRepo.createdDocs) != 1 || docRepo.createdDocs[0].Handle != "release-notes" { + t.Errorf("stored doc did not pick up the generated handle: %+v", docRepo.createdDocs) + } +} + +// When the content type is text/markdown and no filename was uploaded, the +// service synthesises `.md` so the Content-Disposition on download +// is sensible. This exercises the branch after handle generation. +func TestAPIDocumentService_CreateDocument_SynthesisesMarkdownFileName(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + // Inline-content path: contentTypeForDocType returns text/markdown; the + // filename isn't supplied by the caller — the service fills it from the + // handle so a later download has a sensible Content-Disposition name. + req := &dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + Handle: "overview", + DisplayName: "Overview", + Content: []byte("# body"), + } + if _, err := svc.CreateDocument(req, "org", "alice", "artifact-1"); err != nil { + t.Fatalf("err = %v", err) + } + if got := docRepo.createdDocs[0].FileName; got != "overview.md" { + t.Errorf("fileName = %q, want overview.md", got) + } +} + +// CreateDocument propagates a unique-violation from the repository as a +// Conflict apperror. Reaching this is behaviourally different from the +// handler's pre-check for existing handles: a concurrent writer can slip +// between the check and the insert, and the repo-level unique index must +// catch it. +func TestAPIDocumentService_CreateDocument_UniqueViolationIsConflict(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + // IsUniqueViolation checks the error message for SQLite / Postgres / + // SQL Server markers; the SQLite flavour is enough here. + docRepo.createDocErr = errors.New("UNIQUE constraint failed: api_documents.handle") + + _, err := svc.CreateDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, Handle: "x", + DisplayName: "X", Content: []byte("x"), + }, "org", "alice", "artifact-1") + if err == nil || !apperror.Conflict.Is(err) { + t.Fatalf("err = %v, want Conflict", err) + } +} + +// CreateApiDocument's user-facing validation gates are reached BEFORE +// CreateDocument. Each case trips one gate and must not reach the repo. +func TestAPIDocumentService_CreateApiDocument_ValidationGates(t *testing.T) { + longName := strings.Repeat("a", maxDocDisplayNameLen+1) + longFile := strings.Repeat("a", maxDocFileNameLen+1) + longOther := strings.Repeat("a", maxDocTypeLen) // longer than allowed after DOC_ prefix + + cases := []struct { + name string + req *dto.CreateAPIDocumentRequest + }{ + {"nil request", nil}, + {"unknown type", &dto.CreateAPIDocumentRequest{Type: "Bogus", DisplayName: "x", Content: []byte("x")}}, + {"other with forbidden custom name", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeOther, OtherTypeName: constants.DocumentTypeHowTo, DisplayName: "x", Content: []byte("x")}}, + {"other with too-long custom name", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeOther, OtherTypeName: longOther, DisplayName: "x", Content: []byte("x")}}, + {"missing displayName", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeHowTo, Content: []byte("x")}}, + {"blank displayName", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeHowTo, DisplayName: " ", Content: []byte("x")}}, + {"too-long displayName", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeHowTo, DisplayName: longName, Content: []byte("x")}}, + {"too-long fileName", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeHowTo, DisplayName: "x", FileName: longFile, Content: []byte("x")}}, + {"reserved handle", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeHowTo, Handle: constants.DocumentHandleDefinition, DisplayName: "x", Content: []byte("x")}}, + {"malformed handle", &dto.CreateAPIDocumentRequest{Type: constants.DocumentTypeHowTo, Handle: "Not A Handle", DisplayName: "x", Content: []byte("x")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + _, err := svc.CreateApiDocument(tc.req, "org", "alice", "artifact-1") + if err == nil { + t.Fatalf("err = nil, want validation error") + } + if len(docRepo.createdDocs) != 0 { + t.Errorf("invalid request reached the repo: %+v", docRepo.createdDocs) + } + }) + } +} + +// CreateApiDocument short-circuits with a Conflict when the user-supplied +// handle already exists on the artifact. +func TestAPIDocumentService_CreateApiDocument_DuplicateHandleIsConflict(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.handleExistsResult = true + + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeHowTo, Handle: "existing", + DisplayName: "Dup", Content: []byte("x"), + }, "org", "alice", "artifact-1") + if err == nil || !apperror.Conflict.Is(err) { + t.Fatalf("err = %v, want Conflict", err) + } + if len(docRepo.createdDocs) != 0 { + t.Errorf("Create reached the repo despite the pre-check conflict") + } +} + +// A failure of the handle-existence check surfaces as Internal — the service +// cannot know whether the handle is free, so it refuses to proceed. +func TestAPIDocumentService_CreateApiDocument_HandleExistsCheckErrorIsInternal(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.handleExistsErr = errors.New("db down") + + _, err := svc.CreateApiDocument(&dto.CreateAPIDocumentRequest{ + Type: constants.DocumentTypeHowTo, Handle: "overview", + DisplayName: "x", Content: []byte("x"), + }, "org", "alice", "artifact-1") + if err == nil || !apperror.Internal.Is(err) { + t.Fatalf("err = %v, want Internal", err) + } +} + +// UpdateApiDocument's validation gates. Each must fail before UpdateApiDocument +// reaches the repo — i.e. the Update call on the mock never happens. +func TestAPIDocumentService_UpdateApiDocument_ValidationGates(t *testing.T) { + longName := strings.Repeat("a", maxDocDisplayNameLen+1) + longFile := strings.Repeat("a", maxDocFileNameLen+1) + longOther := strings.Repeat("a", maxDocTypeLen) + emptyName := "" + blankName := " " + + cases := []struct { + name string + req *dto.UpdateAPIDocumentRequest + handle string + seedExist bool + }{ + {"nil request", nil, "overview", true}, + {"reserved handle", &dto.UpdateAPIDocumentRequest{}, constants.DocumentHandleThumbnail, true}, + {"invalid handle", &dto.UpdateAPIDocumentRequest{}, "Not A Handle", true}, + {"not found", &dto.UpdateAPIDocumentRequest{DisplayName: strPtr("x")}, "overview", false}, + {"invalid type", &dto.UpdateAPIDocumentRequest{Type: strPtr("Bogus")}, "overview", true}, + {"other with forbidden custom name", &dto.UpdateAPIDocumentRequest{ + Type: strPtr(constants.DocumentTypeOther), OtherTypeName: constants.DocumentTypeHowTo, + }, "overview", true}, + {"other with too-long custom name", &dto.UpdateAPIDocumentRequest{ + Type: strPtr(constants.DocumentTypeOther), OtherTypeName: longOther, + }, "overview", true}, + {"blank displayName", &dto.UpdateAPIDocumentRequest{DisplayName: &blankName}, "overview", true}, + {"empty displayName", &dto.UpdateAPIDocumentRequest{DisplayName: &emptyName}, "overview", true}, + {"too-long displayName", &dto.UpdateAPIDocumentRequest{DisplayName: &longName}, "overview", true}, + {"too-long fileName", &dto.UpdateAPIDocumentRequest{FileName: &longFile}, "overview", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + if tc.seedExist { + docRepo.getDocResult = &model.Document{ + ArtifactUUID: "artifact-1", OrganizationUUID: "org", + Handle: tc.handle, Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + DisplayName: "Original", + } + } + err := svc.UpdateApiDocument(tc.req, "org", "alice", "artifact-1", tc.handle) + if err == nil { + t.Fatalf("err = nil, want validation error") + } + if len(docRepo.updateApiDocCalls) != 0 { + t.Errorf("invalid request reached the repo update: %+v", docRepo.updateApiDocCalls) + } + }) + } +} + +// (Reserved-handle rejection is already covered elsewhere in this file by +// TestAPIDocumentService_DeleteApiDocument_RejectsReservedHandle; the three +// extra DeleteApiDocument branch tests below pick up where that one stops.) + +func TestAPIDocumentService_DeleteApiDocument_RequiresArtifactAndHandle(t *testing.T) { + svc, _, _, _ := newTestDocumentService() + if err := svc.DeleteApiDocument("", "overview", "org", "alice"); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty artifact: err = %v, want ValidationFailed", err) + } + if err := svc.DeleteApiDocument("artifact-1", "", "org", "alice"); err == nil || !apperror.ValidationFailed.Is(err) { + t.Errorf("empty handle: err = %v, want ValidationFailed", err) + } +} + +func TestAPIDocumentService_DeleteApiDocument_NotFound(t *testing.T) { + // No getDocResult seeded → service returns NotFound before touching the + // repo delete. + svc, docRepo, _, _ := newTestDocumentService() + err := svc.DeleteApiDocument("artifact-1", "overview", "org", "alice") + if err == nil || !apperror.NotFound.Is(err) { + t.Fatalf("err = %v, want NotFound", err) + } + if len(docRepo.deleteApiDocCalls) != 0 { + t.Error("not-found path must not reach the repo delete") + } +} + +// A race: the row disappears between the service's existence check and the +// repo's delete. The service maps sql.ErrNoRows to NotFound rather than +// surfacing the driver error. +func TestAPIDocumentService_DeleteApiDocument_RaceMapsToNotFound(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + ArtifactUUID: "artifact-1", OrganizationUUID: "org", Handle: "overview", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + } + docRepo.deleteApiDocErr = sql.ErrNoRows + + err := svc.DeleteApiDocument("artifact-1", "overview", "org", "alice") + if err == nil || !apperror.NotFound.Is(err) { + t.Fatalf("err = %v, want NotFound", err) + } +} + +func TestAPIDocumentService_DeleteApiDocument_RepoErrorIsSurfaced(t *testing.T) { + svc, docRepo, _, _ := newTestDocumentService() + docRepo.getDocResult = &model.Document{ + ArtifactUUID: "artifact-1", OrganizationUUID: "org", Handle: "overview", + Type: constants.DocumentTypePrefix + constants.DocumentTypeHowTo, + } + docRepo.deleteApiDocErr = errors.New("db down") + + if err := svc.DeleteApiDocument("artifact-1", "overview", "org", "alice"); err == nil { + t.Fatal("err = nil, want the repo error to surface") + } +} + diff --git a/platform-api/resources/openapi.yaml b/platform-api/resources/openapi.yaml index a74576d9f0..da862b68ef 100644 --- a/platform-api/resources/openapi.yaml +++ b/platform-api/resources/openapi.yaml @@ -1926,6 +1926,303 @@ paths: '503': $ref: '#/components/responses/PortalUnavailable' + /apis/{apiType}/{apiId}/docs: + parameters: + - $ref: '#/components/parameters/apiType' + - $ref: '#/components/parameters/apiHandle' + get: + summary: List API documents + description: Returns metadata-only entries for every user-authored document attached to the API, paginated and optionally filtered by `type`. + operationId: ListAPIDocuments + security: + - OAuth2Security: + - ap:docs:read + - ap:docs:manage + tags: + - API Documents + parameters: + - $ref: '#/components/parameters/docType-Q' + - $ref: '#/components/parameters/limit-Q' + - $ref: '#/components/parameters/offset-Q' + responses: + '200': + description: Documents listed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/APIDocumentListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + post: + summary: Create an API document + description: Creates a user-authored document on the API from a `file` upload or `inlineContent`. + operationId: CreateAPIDocument + security: + - OAuth2Security: + - ap:docs:manage + tags: + - API Documents + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/APIDocumentRequest' + responses: + '201': + description: Document created successfully + headers: + Location: + description: URL of the newly created document. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/APIDocumentMetadata' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '500': + $ref: '#/components/responses/InternalServerError' + + /apis/{apiType}/{apiId}/docs/{docId}: + parameters: + - $ref: '#/components/parameters/apiType' + - $ref: '#/components/parameters/apiHandle' + - $ref: '#/components/parameters/docId' + get: + summary: Get API document metadata + description: | + Returns document metadata only. Use `GET …/{docId}/content` to retrieve + the raw document bytes. + operationId: GetAPIDocument + security: + - OAuth2Security: + - ap:docs:read + - ap:docs:manage + tags: + - API Documents + responses: + '200': + description: Document metadata retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/APIDocumentMetadata' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + summary: Update an API document + description: Updates metadata and/or content on an existing API document. + operationId: UpdateAPIDocument + security: + - OAuth2Security: + - ap:docs:manage + tags: + - API Documents + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/APIDocumentRequest' + responses: + '200': + description: Document updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/APIDocumentMetadata' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + summary: Delete an API document + description: Deletes a user-authored document from the API. + operationId: DeleteAPIDocument + security: + - OAuth2Security: + - ap:docs:manage + tags: + - API Documents + responses: + '204': + description: Document deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /apis/{apiType}/{apiId}/docs/{docId}/content: + parameters: + - $ref: '#/components/parameters/apiType' + - $ref: '#/components/parameters/apiHandle' + - $ref: '#/components/parameters/docId' + get: + summary: Get API document content + description: | + Returns the raw document bytes with the stored `Content-Type` header + (e.g. `text/markdown; charset=utf-8` for markdown documents). + Extensible to any future content format without schema changes — the + stored content-type drives how the client interprets the response body. + A `Content-Disposition: inline; filename="…"` header is included when + a filename is stored. + operationId: GetAPIDocumentContent + security: + - OAuth2Security: + - ap:docs:read + - ap:docs:manage + tags: + - API Documents + responses: + '200': + description: Document content retrieved successfully + content: + '*/*': + schema: + type: string + format: binary + '204': + description: Document exists but has no content stored. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /apis/{apiType}/{apiId}/thumbnail: + parameters: + - $ref: '#/components/parameters/apiType' + - $ref: '#/components/parameters/apiHandle' + get: + summary: Get API thumbnail + description: | + Streams the stored thumbnail bytes with the sniffed `Content-Type` + header (`image/jpeg` or `image/png`). + operationId: GetAPIThumbnail + security: + - OAuth2Security: + - ap:thumbnail:read + - ap:thumbnail:manage + tags: + - API Thumbnail + responses: + '200': + description: Thumbnail bytes + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + '204': + description: No thumbnail is set for this API + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + summary: Set or replace the API thumbnail + description: | + Creates or replaces the API's singleton thumbnail. + operationId: UpsertAPIThumbnail + security: + - OAuth2Security: + - ap:thumbnail:manage + tags: + - API Thumbnail + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/APIThumbnailRequest' + responses: + '204': + description: Thumbnail stored successfully + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete the API thumbnail + description: | + Removes the stored thumbnail. Subsequent `GET` returns `204` (no + thumbnail set) and the client falls back to rendering the API's name initials. + operationId: DeleteAPIThumbnail + security: + - OAuth2Security: + - ap:thumbnail:manage + tags: + - API Thumbnail + responses: + '204': + description: Thumbnail deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /llm-provider-templates: post: summary: Create a new LLM provider template family @@ -7367,6 +7664,8 @@ components: ap:agent_proxy:update: Update an Agent proxy ap:api_key:all:manage: Manage API keys created by any user in the organization ap:api_key:read: Read API keys owned by the current user + ap:docs:manage: Create, update, and delete user-authored API documents + ap:docs:read: Read user-authored API documents ap:api_portal:create: Create an API Portal ap:api_portal:delete: Delete an API Portal ap:api_portal:draft:manage: Read and write an API's draft for one portal @@ -7512,6 +7811,8 @@ components: ap:subscription_plan:manage: Full access to subscription plans ap:subscription_plan:read: Read subscription plans ap:subscription_plan:update: Update a subscription plan + ap:thumbnail:manage: Create, update, and delete an API's thumbnail + ap:thumbnail:read: Read an API's thumbnail schemas: APIKeyItem: type: object @@ -8981,6 +9282,129 @@ components: type: string description: Raw spec content + APIDocumentMetadata: + type: object + description: Metadata-only view of a document attached to an artifact. + required: + - id + - type + - displayName + properties: + id: + type: string + description: URL-safe handle used in the `{docId}` path segment. + minLength: 3 + maxLength: 40 + example: "payment-webhook-howto" + type: + type: string + description: Document type as stored. Fixed types (HowTo, Samples, SupportForum, PublicForum, Other) are returned as-is; custom OTHER types are returned as the bare custom name (e.g. FAQ). + example: HOW_TO + displayName: + type: string + example: Payment Webhook How-To + fileName: + type: string + description: Original file name supplied when a `file` was uploaded. + example: payment-webhook.md + contentType: + type: string + description: Stored MIME type, sniffed from the uploaded bytes rather than trusted from the uploader. + example: text/markdown; charset=utf-8 + createdBy: + type: string + description: User who created the document. + updatedBy: + type: string + description: User who updated the document. + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + APIDocumentListResponse: + type: object + required: + - count + - list + - pagination + properties: + count: + type: integer + description: Number of items in the current page. + example: 2 + list: + type: array + items: + $ref: '#/components/schemas/APIDocumentMetadata' + pagination: + $ref: '#/components/schemas/Pagination' + + APIDocumentRequest: + type: object + description: | + Multipart form for document create (`POST`) and update (`PUT`). + + On **create**: `type` and `displayName` are required; `inlineContent` + must carry the body. `id` is optional — the server generates one from + `displayName` when omitted, and `fileName` defaults to `{handle}.md`. + + On **update**: every field is optional; omitted fields leave the stored + value unchanged. Omitting `inlineContent` means a metadata-only update + — the stored bytes are not touched. If `id` is supplied it must match + the `{docId}` path parameter, otherwise the request is rejected with 400. + required: + - type + - displayName + properties: + id: + type: string + description: | + URL-safe document handle. On create: optional, server-generated from + `displayName` when omitted; must be unique per artifact (409 on + conflict). On update: if provided, must match the `{docId}` path parameter. + example: payment-webhook-howto + type: + type: string + description: | + Document type. Well-known values: `HowTo`, `Samples`, `SupportForum`, + `PublicForum`, `Other`. Custom types are accepted and stored as-is. + example: HowTo + otherTypeName: + type: string + description: | + Free-form qualifier used when `type` is `Other`. Stored and returned + exactly as typed (no case conversion). Ignored for all other types. + example: FAQ + displayName: + type: string + example: Payment Webhook How-To + inlineContent: + type: string + description: Inline UTF-8 Markdown content. + fileName: + type: string + description: | + File name to associate with the content. Defaults to `{handle}.md`. + example: payment-webhook.md + + APIThumbnailRequest: + type: object + description: | + Multipart form for `PUT /apis/{apiType}/{apiId}/thumbnail`. The server + sniffs the uploaded bytes and accepts only `image/jpeg` or `image/png` + — the declared `Content-Type` and filename extension are ignored for + the type decision. + required: + - file + properties: + file: + type: string + format: binary + description: JPEG or PNG image bytes. Max size is deployment-configured. + TimeUnit: type: string description: Time unit for API key expiration duration @@ -13162,6 +13586,27 @@ components: type: string example: my-api-handle + docId: + name: docId + in: path + required: true + description: Document handle (api_documents.handle), unique per API artifact. + schema: + type: string + example: payment-webhook-howto + + docType-Q: + name: type + in: query + required: false + description: | + Optional filter restricting the list to documents of a single type. + An unrecognised value yields an empty page rather than an error, and + the reserved `DEFINITION` type is never returned via this endpoint. + schema: + type: string + example: HowTo + apiType-Q: name: apiType in: query @@ -13290,6 +13735,8 @@ tags: description: API management operations - name: REST API Deployments description: API deployment artifact management and lifecycle operations + - name: API Documents + description: User-authored documents (how-to, support/public forum, other) attached to any API artifact - name: API Publications description: Publishing, unpublishing and deprecating an API on an API Portal, and the per-portal draft and live listing that feed those actions - name: API Portals diff --git a/platform-api/resources/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml index cdbe813c44..af57d98ebc 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -68,6 +68,8 @@ roles: - ap:gateway:manage - ap:gateway_custom_policy:manage - ap:rest_api:manage + - ap:docs:manage + - ap:thumbnail:manage - ap:application:manage - ap:subscription:manage - ap:subscription_plan:manage @@ -120,6 +122,8 @@ roles: - ap:gateway:manage - ap:gateway_custom_policy:manage - ap:rest_api:read + - ap:docs:read + - ap:thumbnail:read - ap:rest_api:deployment:manage - ap:llm_template:manage - ap:llm_provider:read @@ -175,6 +179,8 @@ roles: - ap:organization:read - ap:project:manage - ap:rest_api:manage + - ap:docs:manage + - ap:thumbnail:manage - ap:mcp_proxy:manage - ap:agent_proxy:manage - ap:llm_provider:read @@ -223,6 +229,8 @@ roles: - ap:subscription:manage - ap:subscription_plan:read - ap:rest_api:read + - ap:docs:read + - ap:thumbnail:read - ap:mcp_proxy:read - ap:agent_proxy:read - ap:llm_proxy:read @@ -256,6 +264,8 @@ roles: - ap:gateway:token:read - ap:gateway_custom_policy:read - ap:rest_api:read + - ap:docs:read + - ap:thumbnail:read - ap:rest_api:deployment:read - ap:application:read - ap:application:api_key:read diff --git a/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go b/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go index be7c4a3f6b..c08c58e05b 100644 --- a/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go +++ b/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go @@ -113,6 +113,8 @@ var excludedScopeResources = []string{ // MCP proxy — that gets its own scopes (see ap:api_portal:mcp_proxy: above) once it // ships. Consistent with ap:rest_api: itself being excluded. "ap:api_portal:rest_api:", + "ap:docs:", + "ap:thumbnail:", } func isExcludedScope(scope string) bool { diff --git a/portals/api-control-plane/bff/internal/config/config.go b/portals/api-control-plane/bff/internal/config/config.go index a9f3bd9150..30c7bacb58 100644 --- a/portals/api-control-plane/bff/internal/config/config.go +++ b/portals/api-control-plane/bff/internal/config/config.go @@ -285,7 +285,8 @@ const defaultOIDCScopes = "openid profile email offline_access" + " ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage" + " ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage" + " ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage" + - " ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" + " ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" + + " ap:docs:read ap:docs:manage ap:thumbnail:read ap:thumbnail:manage" // Load resolves configuration from one or more config.toml files. At least one // path is required and each must exist and parse — there is no default path and diff --git a/portals/api-control-plane/src/api/core/errors.ts b/portals/api-control-plane/src/api/core/errors.ts index 51887cb4a7..c134b8349c 100644 --- a/portals/api-control-plane/src/api/core/errors.ts +++ b/portals/api-control-plane/src/api/core/errors.ts @@ -82,6 +82,8 @@ export const ErrorCode = { POLICY_INVALID_STATE: 'POLICY_INVALID_STATE', // named only in the `code` field's own example REST_API_NOT_FOUND: 'REST_API_NOT_FOUND', + // API document domain + API_DOCUMENT_NAME_EXISTS: 'API_DOCUMENT_NAME_EXISTS', } as const; /** diff --git a/portals/api-control-plane/src/api/core/http.ts b/portals/api-control-plane/src/api/core/http.ts index 52a270f1a0..96ac0a6d81 100644 --- a/portals/api-control-plane/src/api/core/http.ts +++ b/portals/api-control-plane/src/api/core/http.ts @@ -389,7 +389,7 @@ async function send( method: string, path: string, options: RequestOptions, - responseType?: 'text' + responseType?: 'text' | 'blob' ): Promise> { const verb = method.toUpperCase(); const requestId = newRequestId(); @@ -491,6 +491,37 @@ export async function requestText( }; } +/** A binary response body, with the content type the server labelled it with. */ +export type BlobResponse = { + blob: Blob; + contentType: string; +}; + +/** + * Like {@link request}, for endpoints that return raw bytes (images, PDFs, ...). + * The body comes back as a `Blob` so the caller can `URL.createObjectURL(blob)` + * for an `` or save it to disk. Errors are still `ApiError`. + * + * Returns `null` for 204/205 No Content — endpoints that use the empty response + * as a typed "not set" signal (e.g. `GET /thumbnail` when no thumbnail exists). + */ +export async function requestBlob( + method: string, + path: string, + options: RequestOptions = {} +): Promise { + const response = await send(method, path, options, 'blob'); + if (response.status === 204 || response.status === 205) { + return null; + } + const contentType = String(response.headers['content-type'] ?? ''); + const blob = + response.data instanceof Blob + ? response.data + : new Blob([response.data as BlobPart], { type: contentType }); + return { blob, contentType }; +} + /* -------------------------------------------------------------------------- */ /* Public surface */ /* -------------------------------------------------------------------------- */ @@ -513,6 +544,10 @@ export const http = { getText: (path: string, options?: BodylessOptions) => requestText('GET', path, options), + /** GET raw bytes as a Blob plus its content type (images, downloads, etc.). */ + getBlob: (path: string, options?: BodylessOptions) => + requestBlob('GET', path, options), + post: (path: string, body?: unknown, options?: BodylessOptions) => request('POST', path, { ...options, body }), diff --git a/portals/api-control-plane/src/api/generated/operationScopes.ts b/portals/api-control-plane/src/api/generated/operationScopes.ts index 26cf5f90f3..72464519c3 100644 --- a/portals/api-control-plane/src/api/generated/operationScopes.ts +++ b/portals/api-control-plane/src/api/generated/operationScopes.ts @@ -77,6 +77,8 @@ export type ApScope = | 'ap:application:manage' | 'ap:application:read' | 'ap:application:update' + | 'ap:docs:manage' + | 'ap:docs:read' | 'ap:gateway:create' | 'ap:gateway:delete' | 'ap:gateway:manage' @@ -193,7 +195,9 @@ export type ApScope = | 'ap:subscription_plan:delete' | 'ap:subscription_plan:manage' | 'ap:subscription_plan:read' - | 'ap:subscription_plan:update'; + | 'ap:subscription_plan:update' + | 'ap:thumbnail:manage' + | 'ap:thumbnail:read'; /** * The full scope catalog, for building test personas and for validating an @@ -250,6 +254,8 @@ export const AP_SCOPES: readonly ApScope[] = [ 'ap:application:manage', 'ap:application:read', 'ap:application:update', + 'ap:docs:manage', + 'ap:docs:read', 'ap:gateway:create', 'ap:gateway:delete', 'ap:gateway:manage', @@ -367,6 +373,8 @@ export const AP_SCOPES: readonly ApScope[] = [ 'ap:subscription_plan:manage', 'ap:subscription_plan:read', 'ap:subscription_plan:update', + 'ap:thumbnail:manage', + 'ap:thumbnail:read', ]; /** @@ -415,6 +423,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:manage', 'ap:agent_proxy:manage', ], + CreateAPIDocument: ['ap:docs:manage'], CreateAPIKey: [ 'ap:api_key:all:manage', 'ap:rest_api:api_key:create', @@ -472,7 +481,9 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:manage', 'ap:agent_proxy:manage', ], + DeleteAPIDocument: ['ap:docs:manage'], DeleteApiPortal: ['ap:api_portal:delete', 'ap:api_portal:manage'], + DeleteAPIThumbnail: ['ap:thumbnail:manage'], DeleteApplication: ['ap:application:delete', 'ap:application:manage'], DeleteBuild: ['ap:rest_api:build:delete', 'ap:rest_api:build:manage', 'ap:rest_api:manage'], DeleteDeployment: [ @@ -570,6 +581,8 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:read', 'ap:agent_proxy:manage', ], + GetAPIDocument: ['ap:docs:manage', 'ap:docs:read'], + GetAPIDocumentContent: ['ap:docs:manage', 'ap:docs:read'], GetApiPortal: ['ap:api_portal:manage', 'ap:api_portal:read'], getApiPublication: ['ap:api_portal:publication:read'], getApiPublicationDefinition: ['ap:api_portal:publication:read'], @@ -579,6 +592,7 @@ export const OPERATION_SCOPES = { getApiPublicationDraftThumbnail: ['ap:api_portal:draft:manage', 'ap:api_portal:draft:read'], getApiPublicationLandingPage: ['ap:api_portal:publication:read'], getApiPublicationThumbnail: ['ap:api_portal:publication:read'], + GetAPIThumbnail: ['ap:thumbnail:manage', 'ap:thumbnail:read'], GetApplication: ['ap:application:manage', 'ap:application:read'], GetBuild: ['ap:rest_api:build:manage', 'ap:rest_api:build:read', 'ap:rest_api:manage'], GetBuilds: ['ap:rest_api:build:manage', 'ap:rest_api:build:read', 'ap:rest_api:manage'], @@ -684,6 +698,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:deployment:read', 'ap:agent_proxy:manage', ], + ListAPIDocuments: ['ap:docs:manage', 'ap:docs:read'], ListApiPortals: ['ap:api_portal:manage', 'ap:api_portal:read'], listApiPublications: ['ap:api_publication:read'], ListApplicationAPIKeys: [ @@ -830,6 +845,7 @@ export const OPERATION_SCOPES = { 'ap:agent_proxy:manage', 'ap:api_key:all:manage', ], + UpdateAPIDocument: ['ap:docs:manage'], UpdateAPIKey: [ 'ap:api_key:all:manage', 'ap:rest_api:api_key:manage', @@ -848,5 +864,6 @@ export const OPERATION_SCOPES = { UpdateRESTAPISpec: ['ap:rest_api:manage', 'ap:rest_api:update'], UpdateSubscription: ['ap:subscription:manage', 'ap:subscription:update'], UpdateSubscriptionPlan: ['ap:subscription_plan:manage', 'ap:subscription_plan:update'], + UpsertAPIThumbnail: ['ap:thumbnail:manage'], ValidateOpenAPISpec: ['ap:rest_api:create', 'ap:rest_api:manage'], } as const satisfies Record; diff --git a/portals/api-control-plane/src/api/generated/platform.d.ts b/portals/api-control-plane/src/api/generated/platform.d.ts index 8f31959fdc..676f642576 100644 --- a/portals/api-control-plane/src/api/generated/platform.d.ts +++ b/portals/api-control-plane/src/api/generated/platform.d.ts @@ -851,6 +851,138 @@ export interface paths { patch?: never; trace?: never; }; + "/apis/{apiType}/{apiId}/docs": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + /** + * List API documents + * @description Returns metadata-only entries for every user-authored document attached to the API, paginated and optionally filtered by `type`. + */ + get: operations["ListAPIDocuments"]; + put?: never; + /** + * Create an API document + * @description Creates a user-authored document on the API from a `file` upload or `inlineContent`. + */ + post: operations["CreateAPIDocument"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apis/{apiType}/{apiId}/docs/{docId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: components["parameters"]["docId"]; + }; + cookie?: never; + }; + /** + * Get API document metadata + * @description Returns document metadata only. Use `GET …/{docId}/content` to retrieve + * the raw document bytes. + */ + get: operations["GetAPIDocument"]; + /** + * Update an API document + * @description Updates metadata and/or content on an existing API document. + */ + put: operations["UpdateAPIDocument"]; + post?: never; + /** + * Delete an API document + * @description Deletes a user-authored document from the API. + */ + delete: operations["DeleteAPIDocument"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apis/{apiType}/{apiId}/docs/{docId}/content": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: components["parameters"]["docId"]; + }; + cookie?: never; + }; + /** + * Get API document content + * @description Returns the raw document bytes with the stored `Content-Type` header + * (e.g. `text/markdown; charset=utf-8` for markdown documents). + * Extensible to any future content format without schema changes — the + * stored content-type drives how the client interprets the response body. + * A `Content-Disposition: inline; filename="…"` header is included when + * a filename is stored. + */ + get: operations["GetAPIDocumentContent"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apis/{apiType}/{apiId}/thumbnail": { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + /** + * Get API thumbnail + * @description Streams the stored thumbnail bytes with the sniffed `Content-Type` + * header (`image/jpeg` or `image/png`). + */ + get: operations["GetAPIThumbnail"]; + /** + * Set or replace the API thumbnail + * @description Creates or replaces the API's singleton thumbnail. + */ + put: operations["UpsertAPIThumbnail"]; + post?: never; + /** + * Delete the API thumbnail + * @description Removes the stored thumbnail. Subsequent `GET` returns `204` (no + * thumbnail set) and the client falls back to rendering the API's name initials. + */ + delete: operations["DeleteAPIThumbnail"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/llm-provider-templates": { parameters: { query?: never; @@ -3903,6 +4035,103 @@ export interface components { /** @description Raw spec content */ content?: string; }; + /** @description Metadata-only view of a document attached to an artifact. */ + APIDocumentMetadata: { + /** + * @description URL-safe handle used in the `{docId}` path segment. + * @example payment-webhook-howto + */ + id: string; + /** + * @description Document type as stored. Fixed types (HOW_TO, SAMPLE_SDK, SUPPORT_FORUM, PUBLIC_FORUM) are returned as-is; custom OTHER types are returned as the bare custom name (e.g. FAQ). + * @example HOW_TO + */ + type: string; + /** @example Payment Webhook How-To */ + displayName: string; + /** + * @description Original file name supplied when a `file` was uploaded. + * @example payment-webhook.md + */ + fileName?: string; + /** + * @description Stored MIME type, sniffed from the uploaded bytes rather than trusted from the uploader. + * @example text/markdown; charset=utf-8 + */ + contentType?: string; + /** @description User who created the document. */ + createdBy?: string; + /** @description User who updated the document. */ + updatedBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + APIDocumentListResponse: { + /** + * @description Number of items in the current page. + * @example 2 + */ + count: number; + list: components["schemas"]["APIDocumentMetadata"][]; + pagination: components["schemas"]["Pagination"]; + }; + /** + * @description Multipart form for document create (`POST`) and update (`PUT`). + * + * On **create**: `type` and `displayName` are required; `inlineContent` + * must carry the body. `id` is optional — the server generates one from + * `displayName` when omitted, and `fileName` defaults to `{handle}.md`. + * + * On **update**: every field is optional; omitted fields leave the stored + * value unchanged. Omitting `inlineContent` means a metadata-only update + * — the stored bytes are not touched. If `id` is supplied it must match + * the `{docId}` path parameter, otherwise the request is rejected with 400. + */ + APIDocumentRequest: { + /** + * @description URL-safe document handle. On create: optional, server-generated from + * `displayName` when omitted; must be unique per artifact (409 on + * conflict). On update: if provided, must match the `{docId}` path parameter. + * @example payment-webhook-howto + */ + id?: string; + /** + * @description Document type. Well-known values: `HowTo`, `Samples`, `SupportForum`, + * `PublicForum`, `Other`. Custom types are accepted and stored as-is. + * @example HowTo + */ + type: string; + /** + * @description Free-form qualifier used when `type` is `Other`. Stored and returned + * exactly as typed (no case conversion). Ignored for all other types. + * @example FAQ + */ + otherTypeName?: string; + /** @example Payment Webhook How-To */ + displayName: string; + /** @description Inline UTF-8 Markdown content. */ + inlineContent?: string; + /** + * @description File name to associate with the content. Defaults to `{handle}.md`. + * @example payment-webhook.md + */ + fileName?: string; + }; + /** + * @description Multipart form for `PUT /apis/{apiType}/{apiId}/thumbnail`. The server + * sniffs the uploaded bytes and accepts only `image/jpeg` or `image/png` + * — the declared `Content-Type` and filename extension are ignored for + * the type decision. + */ + APIThumbnailRequest: { + /** + * Format: binary + * @description JPEG or PNG image bytes. Max size is deployment-configured. + */ + file: string; + }; /** * @description Time unit for API key expiration duration * @example days @@ -6902,6 +7131,14 @@ export interface components { apiType: string; /** @description The API's handle, unique per organization within its own type. */ apiHandle: string; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: string; + /** + * @description Optional filter restricting the list to documents of a single type. + * An unrecognised value yields an empty page rather than an error, and + * the reserved `DEFINITION` type is never returned via this endpoint. + */ + "docType-Q": string; /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ "apiType-Q": string; /** @description The API's handle, unique per organization within its own type. */ @@ -8471,6 +8708,316 @@ export interface operations { 503: components["responses"]["PortalUnavailable"]; }; }; + ListAPIDocuments: { + parameters: { + query?: { + /** + * @description Optional filter restricting the list to documents of a single type. + * An unrecognised value yields an empty page rather than an error, and + * the reserved `DEFINITION` type is never returned via this endpoint. + */ + type?: components["parameters"]["docType-Q"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["limit-Q"]; + /** @description Zero-based index of the first item to return. */ + offset?: components["parameters"]["offset-Q"]; + }; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Documents listed successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIDocumentListResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + CreateAPIDocument: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["APIDocumentRequest"]; + }; + }; + responses: { + /** @description Document created successfully */ + 201: { + headers: { + /** @description URL of the newly created document. */ + Location?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIDocumentMetadata"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetAPIDocument: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: components["parameters"]["docId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Document metadata retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIDocumentMetadata"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + UpdateAPIDocument: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: components["parameters"]["docId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["APIDocumentRequest"]; + }; + }; + responses: { + /** @description Document updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIDocumentMetadata"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeleteAPIDocument: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: components["parameters"]["docId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Document deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetAPIDocumentContent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + /** @description Document handle (api_documents.handle), unique per API artifact. */ + docId: components["parameters"]["docId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Document content retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description Document exists but has no content stored. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + GetAPIThumbnail: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Thumbnail bytes */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "image/jpeg": string; + "image/png": string; + }; + }; + /** @description No thumbnail is set for this API */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + UpsertAPIThumbnail: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["APIThumbnailRequest"]; + }; + }; + responses: { + /** @description Thumbnail stored successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 413: components["responses"]["PayloadTooLarge"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + DeleteAPIThumbnail: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The API's type, required alongside apiId because a handle is unique only within its own type. Known values: rest-api, websub-api, webbroker-api. Values are resolved at runtime, so a type contributed by a plugin is accepted only on a build that includes it. An unrecognised value returns 404. */ + apiType: components["parameters"]["apiType"]; + /** @description The API's handle, unique per organization within its own type. */ + apiId: components["parameters"]["apiHandle"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Thumbnail deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; listLLMProviderTemplates: { parameters: { query?: { diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.test.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.test.ts new file mode 100644 index 0000000000..123bb49dd6 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { http as mswHttp, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + accepts, + apiUrl, + failure, + listEnvelope, + noContent, + recorder, + resource, + type Recorder, +} from '../../../test/msw'; +import { server } from '../../../test/server'; +import { ApiError } from '../../core/errors'; +import { resetHttpClient } from '../../core/http'; +import { + createApiDocument, + deleteApiDocument, + getApiDocument, + getApiDocumentContent, + listApiDocuments, + updateApiDocument, + type ApiDocument, +} from './apiDocuments.endpoints'; +import { nextDocumentsOffset } from './apiDocuments.queries'; + +/** + * Contract tests for `/apis/{apiType}/{apiId}/docs`. + * + * Writes are multipart. As in the secrets tests, jsdom cannot serialise a + * FormData body onto the wire, so what is asserted is that a multipart body is + * built (not labelled JSON) and that absent optional fields stay absent. + */ + +const COLLECTION = '/apis/rest-api/orders-api/docs'; + +const aDocument = (overrides: Partial = {}): ApiDocument => ({ + contentType: 'text/markdown; charset=utf-8', + displayName: 'Getting started', + id: 'getting-started', + type: 'HowTo', + ...overrides, +}); + +let requests: Recorder; + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); +}); + +describe('listApiDocuments', () => { + it('GETs the API’s collection with paging and type filter', async () => { + server.use(resource(COLLECTION, listEnvelope([]), { record: requests })); + + await listApiDocuments('rest-api', 'orders-api', { limit: 5, offset: 10, type: 'HowTo' }); + + const request = requests.last(); + expect(request?.method).toBe('GET'); + expect(request?.url.pathname).toBe('/api/v0.9/apis/rest-api/orders-api/docs'); + expect(request?.params.get('limit')).toBe('5'); + expect(request?.params.get('offset')).toBe('10'); + expect(request?.params.get('type')).toBe('HowTo'); + }); + + it('URL-encodes the API handle', async () => { + server.use(resource('/apis/rest-api/:apiId/docs', listEnvelope([]), { record: requests })); + + await listApiDocuments('rest-api', 'a b/c'); + + expect(requests.last()?.url.pathname).toBe('/api/v0.9/apis/rest-api/a%20b%2Fc/docs'); + }); +}); + +describe('getApiDocument', () => { + it('GETs one document’s metadata', async () => { + server.use(resource(`${COLLECTION}/getting-started`, aDocument(), { record: requests })); + + const document = await getApiDocument('rest-api', 'orders-api', 'getting-started'); + + expect(document.displayName).toBe('Getting started'); + expect(requests.last()?.url.pathname).toBe('/api/v0.9/apis/rest-api/orders-api/docs/getting-started'); + }); + + it('surfaces a missing document as NOT_FOUND', async () => { + server.use(failure('get', `${COLLECTION}/gone`, 404, 'NOT_FOUND')); + + await expect(getApiDocument('rest-api', 'orders-api', 'gone')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + await expect(getApiDocument('rest-api', 'orders-api', 'gone')).rejects.toBeInstanceOf(ApiError); + }); +}); + +describe('getApiDocumentContent', () => { + it('GETs the content sub-resource as text with its stored content type', async () => { + server.use( + mswHttp.get(apiUrl(`${COLLECTION}/getting-started/content`), async ({ request }) => { + await requests.capture(request); + return new HttpResponse('# Getting started\n\n{"not": "parsed"}', { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }); + }) + ); + + const content = await getApiDocumentContent('rest-api', 'orders-api', 'getting-started'); + + expect(requests.last()?.url.pathname).toBe( + '/api/v0.9/apis/rest-api/orders-api/docs/getting-started/content' + ); + // Left as text: a body that happens to contain JSON is not parsed. + expect(content).toEqual({ + contentType: 'text/markdown; charset=utf-8', + text: '# Getting started\n\n{"not": "parsed"}', + }); + }); + + it('reads a 204 (nothing stored) as empty text', async () => { + server.use( + mswHttp.get(apiUrl(`${COLLECTION}/empty/content`), () => new HttpResponse(null, { status: 204 })) + ); + + const content = await getApiDocumentContent('rest-api', 'orders-api', 'empty'); + + expect(content.text).toBe(''); + }); +}); + +describe('createApiDocument', () => { + it('POSTs a multipart body, omitting absent fields', async () => { + server.use(accepts('post', COLLECTION, aDocument(), { record: requests })); + + await createApiDocument('rest-api', 'orders-api', { + displayName: 'Getting started', + fileName: undefined, + inlineContent: '# Getting started', + type: 'HowTo', + }); + + const request = requests.last(); + expect(request?.method).toBe('POST'); + expect(request?.headers.get('content-type') ?? '').not.toContain('application/json'); + expect(request?.body).not.toContain('undefined'); + }); +}); + +describe('updateApiDocument', () => { + it('PUTs to the document', async () => { + server.use(accepts('put', `${COLLECTION}/getting-started`, aDocument(), { record: requests })); + + await updateApiDocument('rest-api', 'orders-api', 'getting-started', { displayName: 'Start here', type: 'HowTo' }); + + expect(requests.last()?.method).toBe('PUT'); + expect(requests.last()?.url.pathname).toBe('/api/v0.9/apis/rest-api/orders-api/docs/getting-started'); + }); +}); + +describe('deleteApiDocument', () => { + it('DELETEs the document', async () => { + server.use(noContent('delete', `${COLLECTION}/getting-started`, { record: requests })); + + await deleteApiDocument('rest-api', 'orders-api', 'getting-started'); + + expect(requests.last()?.method).toBe('DELETE'); + }); +}); + +describe('nextDocumentsOffset', () => { + it('advances by the page just loaded until the total is reached', () => { + expect(nextDocumentsOffset(listEnvelope([aDocument()], { limit: 1, offset: 0, total: 3 }))).toBe(1); + expect(nextDocumentsOffset(listEnvelope([aDocument()], { limit: 1, offset: 2, total: 3 }))).toBeUndefined(); + }); + + it('stops on an empty page even if the total disagrees', () => { + expect(nextDocumentsOffset(listEnvelope([], { limit: 10, offset: 10, total: 30 }))).toBeUndefined(); + }); +}); diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.ts new file mode 100644 index 0000000000..fcb19de992 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.endpoints.ts @@ -0,0 +1,157 @@ +/* + * 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 { http, type RequestOptions, type TextResponse } from '../../core/http'; +import type { FormBodyOf, PathOf, QueryOf, ResponseOf, Schema } from '../../core/spec'; + +/** + * Transport layer for user-authored API documents + * (`/apis/{apiType}/{apiId}/docs`). + * + * Documents hang off the generic `/apis/{apiType}/{apiId}` path rather than a + * per-kind one, so every call names the API's type alongside its handle — a + * handle is unique only within its own type. + * + * Reads are split: `GET …/docs/{docId}` returns metadata only, and the body + * comes from `GET …/docs/{docId}/content` as raw bytes labelled with the stored + * content type, so the console reads it as text and lets the caller decide how + * to render it. + * + * Writes are multipart, per the spec. The console always sends the body as + * `inlineContent` (a file the user uploads is read into the editor first, so + * they can review it before saving) and passes the original file name along + * in `fileName` when there was one. + */ + +export type ApiDocumentType = 'HowTo' | 'Samples' | 'PublicForum' | 'SupportForum' | 'Other'; +export type ApiDocumentMetadata = Schema<'APIDocumentMetadata'>; +/** Metadata of one document — no body. */ +export type ApiDocument = ResponseOf<'GetAPIDocument'>; +/** A document's body as text, with the content type the server stored it under. */ +export type ApiDocumentContent = TextResponse; +export type ApiDocumentListResponse = ResponseOf<'ListAPIDocuments'>; +export type ListApiDocumentsQuery = NonNullable>; +export type CreateApiDocumentBody = FormBodyOf<'CreateAPIDocument'>; +export type CreateApiDocumentResponse = ResponseOf<'CreateAPIDocument'>; +export type UpdateApiDocumentBody = FormBodyOf<'UpdateAPIDocument'>; +export type UpdateApiDocumentResponse = ResponseOf<'UpdateAPIDocument'>; + +type ApiTypeParam = PathOf<'ListAPIDocuments'>['apiType']; +type DocIdParam = PathOf<'GetAPIDocument'>['docId']; + +/** `/apis/{apiType}/{apiId}/docs` — URL-encoded, handles are user-supplied. */ +const collectionPath = (apiType: ApiTypeParam, apiId: string): string => + `/apis/${encodeURIComponent(apiType)}/${encodeURIComponent(apiId)}/docs`; + +const resourcePath = (apiType: ApiTypeParam, apiId: string, docId: DocIdParam): string => + `${collectionPath(apiType, apiId)}/${encodeURIComponent(docId)}`; + +/** + * Turns a typed multipart body into `FormData`. + * + * Absent optional fields are omitted rather than sent as the string + * "undefined", which the server would otherwise store as the field's value. + */ +const toFormData = (body: Record): FormData => { + const form = new FormData(); + for (const [field, value] of Object.entries(body)) { + if (value === undefined || value === null) continue; + form.append(field, value instanceof Blob ? value : String(value)); + } + return form; +}; + +/** One page of document metadata — the list never carries document bodies. */ +export const listApiDocuments = async ( + apiType: string, + apiId: string, + query: ListApiDocumentsQuery = {}, + options?: RequestOptions +): Promise => + http.get(collectionPath(apiType, apiId), { + ...options, + query, + operationName: 'ListAPIDocuments', + }); + +/** One document's metadata. The body is fetched separately — see `getApiDocumentContent`. */ +export const getApiDocument = async ( + apiType: string, + apiId: string, + docId: string, + options?: RequestOptions +): Promise => + http.get(resourcePath(apiType, apiId, docId), { + ...options, + operationName: 'GetAPIDocument', + }); + +/** + * One document's body, as text. A document with nothing stored answers 204, + * which reads as empty text rather than an error. + */ +export const getApiDocumentContent = async ( + apiType: string, + apiId: string, + docId: string, + options?: RequestOptions +): Promise => + http.getText(`${resourcePath(apiType, apiId, docId)}/content`, { + ...options, + operationName: 'GetAPIDocumentContent', + }); + +export const createApiDocument = async ( + apiType: string, + apiId: string, + body: CreateApiDocumentBody, + options?: RequestOptions +): Promise => + http.post(collectionPath(apiType, apiId), toFormData(body), { + ...options, + operationName: 'CreateAPIDocument', + }); + +/** + * Updates a document. Every field is optional; leaving out both `file` and + * `inlineContent` is a metadata-only update that does not touch the stored + * content. + */ +export const updateApiDocument = async ( + apiType: string, + apiId: string, + docId: string, + body: UpdateApiDocumentBody, + options?: RequestOptions +): Promise => + http.put(resourcePath(apiType, apiId, docId), toFormData(body), { + ...options, + operationName: 'UpdateAPIDocument', + }); + +export const deleteApiDocument = async ( + apiType: string, + apiId: string, + docId: string, + options?: RequestOptions +): Promise => { + await http.delete(resourcePath(apiType, apiId, docId), { + ...options, + operationName: 'DeleteAPIDocument', + }); +}; diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.test.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.test.ts new file mode 100644 index 0000000000..0e144e4486 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.test.ts @@ -0,0 +1,364 @@ +/* + * 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 { http as mswHttp, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { waitFor } from '@testing-library/react'; + +import { + apiUrl, + listEnvelope, + noContent, + recorder, + resource, + type Recorder, +} from '../../../test/msw'; +import { renderApiHook, settle } from '../../../test/renderApiHook'; +import { server } from '../../../test/server'; +import { resetHttpClient } from '../../core/http'; +import type { ApiDocument, ApiDocumentListResponse } from './apiDocuments.endpoints'; +import { + useApiDocument, + useApiDocumentContent, + useApiDocumentPages, + useApiDocuments, + useCreateApiDocument, + useDeleteApiDocument, + useUpdateApiDocument, +} from './apiDocuments.hooks'; +import { apiDocumentQueries, nextDocumentsOffset } from './apiDocuments.queries'; + +/** + * Hook-layer tests for API documents. + * + * The React Query behaviours a component test cannot see from the outside live + * here: + * + * 1. Queries wait until the parent API is known (no fetch while `apiId` is + * `undefined`), so switching routes does not fire an abandoned request. + * 2. Writes invalidate every page and filter under the owning API — one + * invalidation covers numbered lists, infinite pages, and open documents. + * 3. Delete removes the deleted doc from every cached list page synchronously + * so the auto-select-first-doc effect can't pick a stale row before the + * refetch returns. + */ + +const API_TYPE = 'rest-api'; +const API_ID = 'orders-api'; +const COLLECTION = `/apis/${API_TYPE}/${API_ID}/docs`; + +const aDocument = (overrides: Partial = {}): ApiDocument => ({ + contentType: 'text/markdown; charset=utf-8', + displayName: 'Getting started', + id: 'getting-started', + type: 'HowTo', + ...overrides, +}); + +let requests: Recorder; + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); +}); + +describe('useApiDocuments — paged list', () => { + it('does not fetch while the apiId is unknown', async () => { + server.use(resource(COLLECTION, listEnvelope([]), { record: requests })); + + renderApiHook(() => useApiDocuments(API_TYPE, undefined)); + await settle(); + + expect(requests.count()).toBe(0); + }); + + it('GETs the API’s collection with the paging and filter query', async () => { + server.use(resource(COLLECTION, listEnvelope([aDocument()]), { record: requests })); + + const { result } = renderApiHook(() => + useApiDocuments(API_TYPE, API_ID, { limit: 5, offset: 10, type: 'HowTo' }) + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const request = requests.last(); + expect(request?.params.get('limit')).toBe('5'); + expect(request?.params.get('offset')).toBe('10'); + expect(request?.params.get('type')).toBe('HowTo'); + expect(result.current.data?.list[0].id).toBe('getting-started'); + }); +}); + +describe('useApiDocumentPages — infinite list', () => { + it('appends pages using the server-reported total, not list length', async () => { + // Two pages of five, total=10. If the hook guessed from "short page" it + // would stop after page 1 because `list.length === limit`; it must read + // `pagination.total` instead. This is exactly what `nextDocumentsOffset` + // encodes, and the hook has to wire it to `getNextPageParam`. + const pageOne = listEnvelope( + Array.from({ length: 5 }, (_, i) => aDocument({ id: `page1-${i}` })), + { offset: 0, limit: 5, total: 10 } + ); + const pageTwo = listEnvelope( + Array.from({ length: 5 }, (_, i) => aDocument({ id: `page2-${i}` })), + { offset: 5, limit: 5, total: 10 } + ); + server.use( + mswHttp.get(apiUrl(COLLECTION), async ({ request }) => { + await requests.capture(request); + const offset = new URL(request.url).searchParams.get('offset') ?? '0'; + return HttpResponse.json(offset === '0' ? pageOne : pageTwo); + }) + ); + + const { result } = renderApiHook(() => useApiDocumentPages(API_TYPE, API_ID, { limit: 5 })); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.hasNextPage).toBe(true); + + await result.current.fetchNextPage(); + await waitFor(() => expect(result.current.data?.pages.length).toBe(2)); + expect(result.current.data?.pages[1].list[0].id).toBe('page2-0'); + // Both pages exhausted — total is reached. + expect(result.current.hasNextPage).toBe(false); + }); + + it('nextDocumentsOffset returns undefined once total is reached', () => { + // The utility `getNextPageParam` is wired to is deterministic — unit-test + // its boundary cases here so the hook integration above does not have to + // enumerate them. + expect( + nextDocumentsOffset({ + count: 5, + list: Array.from({ length: 5 }, (_, i) => aDocument({ id: `d${i}` })), + pagination: { offset: 0, limit: 5, total: 5 }, + }) + ).toBeUndefined(); + expect( + nextDocumentsOffset({ + count: 0, + list: [], + pagination: { offset: 0, limit: 5, total: 0 }, + }) + ).toBeUndefined(); + expect( + nextDocumentsOffset({ + count: 3, + list: Array.from({ length: 3 }, (_, i) => aDocument({ id: `d${i}` })), + pagination: { offset: 0, limit: 5, total: 10 }, + }) + ).toBe(3); + }); +}); + +describe('useApiDocument and useApiDocumentContent', () => { + it('does not fetch metadata while the docId is unknown', async () => { + server.use(resource(`${COLLECTION}/getting-started`, aDocument(), { record: requests })); + + renderApiHook(() => useApiDocument(API_TYPE, API_ID, undefined)); + await settle(); + + expect(requests.count()).toBe(0); + }); + + it('fetches metadata for the given docId', async () => { + server.use(resource(`${COLLECTION}/getting-started`, aDocument(), { record: requests })); + + const { result } = renderApiHook(() => useApiDocument(API_TYPE, API_ID, 'getting-started')); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.id).toBe('getting-started'); + expect(requests.last()?.url.pathname).toBe( + '/api/v0.9/apis/rest-api/orders-api/docs/getting-started' + ); + }); + + it('fetches the content sub-resource as text + content type', async () => { + server.use( + mswHttp.get(apiUrl(`${COLLECTION}/getting-started/content`), async ({ request }) => { + await requests.capture(request); + return new HttpResponse('# Hello', { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }); + }) + ); + + const { result } = renderApiHook(() => + useApiDocumentContent(API_TYPE, API_ID, 'getting-started') + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.text).toBe('# Hello'); + expect(result.current.data?.contentType).toBe('text/markdown; charset=utf-8'); + }); +}); + +describe('useCreateApiDocument', () => { + it('invalidates every page and filter under the owning API on success', async () => { + // Two seeded cache entries sit under this API — one list page with a + // filter, one with none. A single invalidation at the API's detail key + // must mark both as stale, otherwise a user who adds a doc on page 2 would + // still see the stale page 1 until a full refresh. + server.use(resource(COLLECTION, aDocument({ id: 'new-doc' }), { method: 'post', status: 201 })); + + const { result, queryClient, org } = renderApiHook(() => useCreateApiDocument()); + + const noFilterKey = apiDocumentQueries.list(org, API_TYPE, API_ID, {}).queryKey; + const withFilterKey = apiDocumentQueries.list(org, API_TYPE, API_ID, { type: 'HowTo' }) + .queryKey; + const seed: ApiDocumentListResponse = { + count: 0, + list: [], + pagination: { total: 0, offset: 0, limit: 20 }, + }; + queryClient.setQueryData(noFilterKey, seed); + queryClient.setQueryData(withFilterKey, seed); + + result.current.mutate({ + apiType: API_TYPE, + apiId: API_ID, + body: { type: 'HowTo', displayName: 'New Doc', inlineContent: '# body' }, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Both pages — different filters, same parent — must be invalidated. + await waitFor(() => + expect(queryClient.getQueryState(noFilterKey)?.isInvalidated).toBe(true) + ); + await waitFor(() => + expect(queryClient.getQueryState(withFilterKey)?.isInvalidated).toBe(true) + ); + }); +}); + +describe('useUpdateApiDocument', () => { + it('invalidates both metadata and content caches on success', async () => { + // The response is metadata-only, so patching the detail entry would leave + // the separately-cached content stale. A metadata-only PUT changes + // displayName etc. without touching bytes, so detail invalidation is + // obviously required; a content-replacing PUT also needs the content key + // to refetch — the hook invalidates the whole parent, which covers both. + server.use( + resource(`${COLLECTION}/getting-started`, aDocument({ displayName: 'Renamed' }), { + method: 'put', + }) + ); + + const { result, queryClient, org } = renderApiHook(() => useUpdateApiDocument()); + + const detailKey = apiDocumentQueries.detail(org, API_TYPE, API_ID, 'getting-started').queryKey; + const contentKey = apiDocumentQueries.content(org, API_TYPE, API_ID, 'getting-started') + .queryKey; + queryClient.setQueryData(detailKey, aDocument()); + queryClient.setQueryData(contentKey, { + text: '# old', + contentType: 'text/markdown; charset=utf-8', + }); + + result.current.mutate({ + apiType: API_TYPE, + apiId: API_ID, + docId: 'getting-started', + body: { displayName: 'Renamed', type: 'HowTo' }, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + await waitFor(() => expect(queryClient.getQueryState(detailKey)?.isInvalidated).toBe(true)); + await waitFor(() => expect(queryClient.getQueryState(contentKey)?.isInvalidated).toBe(true)); + }); +}); + +describe('useDeleteApiDocument', () => { + it('synchronously removes the deleted doc from cached list pages', async () => { + // The auto-select-first-doc effect in the develop tab reads from the list + // cache. If delete only invalidated and refetched, there is a frame where + // the cache still contains the deleted doc and the effect re-selects it, + // producing a flash of the just-deleted viewer. Patching cached pages in + // place closes that window. + server.use(noContent('delete', `${COLLECTION}/getting-started`)); + + const { result, queryClient, org } = renderApiHook(() => useDeleteApiDocument()); + + const listKey = apiDocumentQueries.list(org, API_TYPE, API_ID, {}).queryKey; + const seededPage: ApiDocumentListResponse = { + count: 2, + list: [aDocument(), aDocument({ id: 'other-doc', displayName: 'Other' })], + pagination: { total: 2, offset: 0, limit: 20 }, + }; + queryClient.setQueryData(listKey, seededPage); + + result.current.mutate({ apiType: API_TYPE, apiId: API_ID, docId: 'getting-started' }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const after = queryClient.getQueryData(listKey); + expect(after?.list.map((d) => d.id)).toEqual(['other-doc']); + expect(after?.count).toBe(1); + expect(after?.pagination.total).toBe(1); + }); + + it('also prunes the deleted doc from cached infinite-query pages', async () => { + // Same guarantee for the overview tab's "View more" list, which uses an + // infinite query. The shape is {pages, pageParams} rather than a single + // envelope, so the delete path has to recognise and update both. + server.use(noContent('delete', `${COLLECTION}/getting-started`)); + + const { result, queryClient, org } = renderApiHook(() => useDeleteApiDocument()); + + const pagesKey = apiDocumentQueries.pages(org, API_TYPE, API_ID, {}).queryKey; + const infinite = { + pages: [ + { + count: 2, + list: [aDocument(), aDocument({ id: 'keep', displayName: 'Keep' })], + pagination: { total: 2, offset: 0, limit: 20 }, + }, + ], + pageParams: [0], + }; + queryClient.setQueryData(pagesKey, infinite); + + result.current.mutate({ apiType: API_TYPE, apiId: API_ID, docId: 'getting-started' }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const after = queryClient.getQueryData(pagesKey); + expect(after?.pages[0].list.map((d) => d.id)).toEqual(['keep']); + expect(after?.pages[0].pagination.total).toBe(1); + }); + + it('removes the deleted doc’s own detail and content cache entries', async () => { + // The viewer reads from the detail cache, so a stale entry would still + // render the deleted doc for one tick after the delete completes. + server.use(noContent('delete', `${COLLECTION}/getting-started`)); + + const { result, queryClient, org } = renderApiHook(() => useDeleteApiDocument()); + + const detailKey = apiDocumentQueries.detail(org, API_TYPE, API_ID, 'getting-started').queryKey; + const contentKey = apiDocumentQueries.content(org, API_TYPE, API_ID, 'getting-started') + .queryKey; + queryClient.setQueryData(detailKey, aDocument()); + queryClient.setQueryData(contentKey, { + text: '# body', + contentType: 'text/markdown; charset=utf-8', + }); + + result.current.mutate({ apiType: API_TYPE, apiId: API_ID, docId: 'getting-started' }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(queryClient.getQueryData(detailKey)).toBeUndefined(); + expect(queryClient.getQueryData(contentKey)).toBeUndefined(); + }); +}); diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.ts new file mode 100644 index 0000000000..0f25afa02d --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.hooks.ts @@ -0,0 +1,217 @@ +/* + * 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 { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import type { ApiError } from '../../core/errors'; +import { useApiScope } from '../../core/scope'; +import { + createApiDocument, + deleteApiDocument, + updateApiDocument, + type ApiDocumentListResponse, + type CreateApiDocumentBody, + type CreateApiDocumentResponse, + type ListApiDocumentsQuery, + type UpdateApiDocumentBody, + type UpdateApiDocumentResponse, +} from './apiDocuments.endpoints'; +import { + apiDocumentKeys, + apiDocumentParentId, + apiDocumentQueries, + type ApiDocumentPagesQuery, +} from './apiDocuments.queries'; + +/** + * The public hook surface for API documents. + * + * Every hook takes the parent API explicitly (`apiType` + `apiId`, the API's + * handle) rather than reading it from route scope, mirroring deployments: the + * page decides which API it is showing, the hook does not guess. + */ + +type Overrides = { orgId?: string }; + +/** One explicit page of document metadata, keeping the previous page on screen while the next loads. */ +export const useApiDocuments = ( + apiType: string, + apiId: string | undefined, + query: ListApiDocumentsQuery = {}, + overrides: Overrides = {} +) => { + const { org } = useApiScope(overrides); + + return useQuery({ + ...apiDocumentQueries.list(org!, apiType, apiId!, query), + enabled: Boolean(org && apiId), + placeholderData: keepPreviousData, + }); +}; + +/** Document metadata loaded a page at a time; call `fetchNextPage` to append the next one. */ +export const useApiDocumentPages = ( + apiType: string, + apiId: string | undefined, + query: ApiDocumentPagesQuery = {}, + overrides: Overrides = {} +) => { + const { org } = useApiScope(overrides); + + return useInfiniteQuery({ + ...apiDocumentQueries.pages(org!, apiType, apiId!, query), + enabled: Boolean(org && apiId), + }); +}; + +/** One document's metadata. */ +export const useApiDocument = ( + apiType: string, + apiId: string | undefined, + docId: string | undefined, + overrides: Overrides = {} +) => { + const { org } = useApiScope(overrides); + + return useQuery({ + ...apiDocumentQueries.detail(org!, apiType, apiId!, docId!), + enabled: Boolean(org && apiId && docId), + }); +}; + +/** One document's body as text, with its stored content type. */ +export const useApiDocumentContent = ( + apiType: string, + apiId: string | undefined, + docId: string | undefined, + overrides: Overrides = {} +) => { + const { org } = useApiScope(overrides); + + return useQuery({ + ...apiDocumentQueries.content(org!, apiType, apiId!, docId!), + enabled: Boolean(org && apiId && docId), + }); +}; + +/** + * Invalidates everything cached under one API's documents: every page and + * filter of both lists (a write changes counts and, since the server orders by + * last update, page membership) plus every open document. + */ +const useInvalidateApiDocuments = (overrides: Overrides) => { + const queryClient = useQueryClient(); + const { org } = useApiScope(overrides); + + return (apiType: string, apiId: string) => { + if (!org) return; + void queryClient.invalidateQueries({ + queryKey: apiDocumentKeys.detail(org, apiDocumentParentId(apiType, apiId)), + }); + }; +}; + +type ParentArgs = { apiType: string; apiId: string }; + +export const useCreateApiDocument = (overrides: Overrides = {}) => { + const { orgId } = useApiScope(overrides); + const invalidate = useInvalidateApiDocuments(overrides); + + return useMutation< + CreateApiDocumentResponse, + ApiError, + ParentArgs & { body: CreateApiDocumentBody } + >({ + mutationFn: ({ apiType, apiId, body }) => createApiDocument(apiType, apiId, body, { orgId }), + onSuccess: (_data, { apiType, apiId }) => invalidate(apiType, apiId), + }); +}; + +export const useUpdateApiDocument = (overrides: Overrides = {}) => { + const { orgId } = useApiScope(overrides); + const invalidate = useInvalidateApiDocuments(overrides); + + return useMutation< + UpdateApiDocumentResponse, + ApiError, + ParentArgs & { docId: string; body: UpdateApiDocumentBody } + >({ + mutationFn: ({ apiType, apiId, docId, body }) => + updateApiDocument(apiType, apiId, docId, body, { orgId }), + // The response is metadata only and the content is cached separately, so + // both are refetched rather than patched. + onSuccess: (_data, { apiType, apiId }) => invalidate(apiType, apiId), + }); +}; + +export const useDeleteApiDocument = (overrides: Overrides = {}) => { + const queryClient = useQueryClient(); + const { org, orgId } = useApiScope(overrides); + const invalidate = useInvalidateApiDocuments(overrides); + + return useMutation({ + mutationFn: ({ apiType, apiId, docId }) => deleteApiDocument(apiType, apiId, docId, { orgId }), + onSuccess: (_data, { apiType, apiId, docId }) => { + // Drop the deleted document outright so nothing can render it from cache + // while the lists refetch. + if (org) { + queryClient.removeQueries({ + queryKey: apiDocumentQueries.detail(org, apiType, apiId, docId).queryKey, + }); + queryClient.removeQueries({ + queryKey: apiDocumentQueries.content(org, apiType, apiId, docId).queryKey, + }); + // Patch every cached list page so the auto-select effect can't pick + // the deleted doc from stale pages before the background refetch runs. + const parentId = apiDocumentParentId(apiType, apiId); + queryClient.setQueriesData( + { queryKey: apiDocumentKeys.children(org, parentId, 'documents') }, + (data) => (data ? removeDocFromPage(data, docId) : data), + ); + queryClient.setQueriesData<{ pages: ApiDocumentListResponse[]; pageParams: unknown[] }>( + { queryKey: apiDocumentKeys.children(org, parentId, 'documentPages') }, + (data) => + data + ? { ...data, pages: data.pages.map((page) => removeDocFromPage(page, docId)) } + : data, + ); + } + invalidate(apiType, apiId); + }, + }); +}; + +const removeDocFromPage = ( + page: ApiDocumentListResponse, + docId: string, +): ApiDocumentListResponse => { + const filtered = page.list.filter((doc) => doc.id !== docId); + if (filtered.length === page.list.length) return page; + return { + ...page, + list: filtered, + count: Math.max(0, page.count - 1), + pagination: { ...page.pagination, total: Math.max(0, page.pagination.total - 1) }, + }; +}; diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.test.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.test.ts new file mode 100644 index 0000000000..271efd83f8 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.test.ts @@ -0,0 +1,154 @@ +/* + * 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 { describe, expect, it } from 'vitest'; + +import { orgScope } from '../../core/queryKeys'; +import { + apiDocumentKeys, + apiDocumentParentId, + apiDocumentQueries, + nextDocumentsOffset, +} from './apiDocuments.queries'; + +/** + * Query-key factories are hashed by TanStack Query to decide what is one entry + * and what is two. Any drift here silently splits or merges cache entries, so + * the shape rules are asserted directly rather than inferred from a hook test. + */ + +const org = orgScope('acme')!; + +describe('apiDocumentParentId', () => { + it('joins apiType and apiId so two API kinds with the same handle do not share cache entries', () => { + // A handle is unique only within its kind: a REST API and an MCP API can + // both be `orders`. Dropping the kind from the parent id would let their + // documents collide in the cache. + expect(apiDocumentParentId('rest-api', 'orders')).toBe('rest-api/orders'); + expect(apiDocumentParentId('mcp', 'orders')).toBe('mcp/orders'); + expect(apiDocumentParentId('rest-api', 'orders')).not.toBe( + apiDocumentParentId('mcp', 'orders') + ); + }); +}); + +describe('apiDocumentQueries key shape', () => { + it('nests every variant under one API detail so a parent invalidation covers them', () => { + // apiDocumentKeys is a child-resource factory: all children sit under + // ['platform', org, 'apiDocuments', 'detail', ''], and + // `useInvalidateApiDocuments` invalidates exactly that prefix. If any + // variant below has a different prefix, that one invalidation would leak + // stale pages onto the UI. + const parentPrefix = apiDocumentKeys.detail(org, apiDocumentParentId('rest-api', 'orders')); + + const list = apiDocumentQueries.list(org, 'rest-api', 'orders', { limit: 20 }).queryKey; + const pages = apiDocumentQueries.pages(org, 'rest-api', 'orders', { type: 'HowTo' }) + .queryKey; + const detail = apiDocumentQueries.detail(org, 'rest-api', 'orders', 'getting-started') + .queryKey; + const content = apiDocumentQueries.content(org, 'rest-api', 'orders', 'getting-started') + .queryKey; + + for (const key of [list, pages, detail, content]) { + expect(key.slice(0, parentPrefix.length)).toEqual([...parentPrefix]); + } + }); + + it('produces structurally distinct keys for each variant', () => { + const list = apiDocumentQueries.list(org, 'rest-api', 'orders').queryKey; + const pages = apiDocumentQueries.pages(org, 'rest-api', 'orders').queryKey; + const detail = apiDocumentQueries.detail(org, 'rest-api', 'orders', 'a').queryKey; + const content = apiDocumentQueries.content(org, 'rest-api', 'orders', 'a').queryKey; + + const serialised = new Set([list, pages, detail, content].map((k) => JSON.stringify(k))); + expect(serialised.size).toBe(4); + }); + + it('two detail requests for the same doc produce equal keys so React Query dedupes', () => { + // `toEqual` is intentional — React Query hashes the key value, not the + // reference. Equal-by-value keys must be one entry in the cache. + const a = apiDocumentQueries.detail(org, 'rest-api', 'orders', 'getting-started').queryKey; + const b = apiDocumentQueries.detail(org, 'rest-api', 'orders', 'getting-started').queryKey; + expect(a).toEqual(b); + expect(a).not.toBe(b); + }); + + it('a filter difference produces a different list key', () => { + const noFilter = apiDocumentQueries.list(org, 'rest-api', 'orders').queryKey; + const howtoFilter = apiDocumentQueries.list(org, 'rest-api', 'orders', { type: 'HowTo' }) + .queryKey; + expect(noFilter).not.toEqual(howtoFilter); + }); + + it('normalises filters that only differ by explicit-undefined, so one cache entry serves both', () => { + // `{ type: undefined }` and `{}` are the same request. If the key factory + // treated them differently every unfiltered page would double-fetch. + const noFilter = apiDocumentQueries.list(org, 'rest-api', 'orders').queryKey; + const emptyExplicit = apiDocumentQueries.list(org, 'rest-api', 'orders', {}).queryKey; + const undefinedValue = apiDocumentQueries.list(org, 'rest-api', 'orders', { type: undefined }) + .queryKey; + expect(noFilter).toEqual(emptyExplicit); + expect(noFilter).toEqual(undefinedValue); + }); + + it('a different docId produces a different content key', () => { + const docA = apiDocumentQueries.content(org, 'rest-api', 'orders', 'a').queryKey; + const docB = apiDocumentQueries.content(org, 'rest-api', 'orders', 'b').queryKey; + expect(docA).not.toEqual(docB); + }); +}); + +describe('nextDocumentsOffset', () => { + // Covered by one case in the hooks test alongside the integration behaviour, + // repeated here as pure-input/output pairs so a regression surfaces on the + // smaller test first. + const page = ( + offset: number, + limit: number, + total: number, + length = limit + ) => ({ + count: length, + list: Array.from({ length }, (_, i) => ({ + id: `doc-${offset}-${i}`, + type: 'HowTo' as const, + displayName: 'd', + })), + pagination: { offset, limit, total }, + }); + + it('returns the next offset when more pages remain', () => { + expect(nextDocumentsOffset(page(0, 5, 10))).toBe(5); + }); + + it('returns undefined when total is reached', () => { + expect(nextDocumentsOffset(page(5, 5, 10))).toBeUndefined(); + }); + + it('returns undefined for an empty page (nothing to append to)', () => { + expect(nextDocumentsOffset(page(0, 20, 0, 0))).toBeUndefined(); + }); + + it('returns undefined when the short-page math alone would hide the end', () => { + // 7 of 7 arrived in two pages of 5 and 2. The second page's `list.length` + // is less than limit but that is a legitimate end — using + // `list.length < limit` to detect "end" would also stop early mid-list on + // a sparse page. The implementation uses total; this test asserts that. + expect(nextDocumentsOffset(page(5, 5, 7, 2))).toBeUndefined(); + }); +}); diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.ts b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.ts new file mode 100644 index 0000000000..a33aa2551d --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/apiDocuments.queries.ts @@ -0,0 +1,105 @@ +/* + * 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 { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; + +import { staleTimes } from '../../core/queryClient'; +import { createResourceKeys, type OrgScope } from '../../core/queryKeys'; +import { + getApiDocument, + getApiDocumentContent, + listApiDocuments, + type ApiDocumentListResponse, + type ListApiDocumentsQuery, +} from './apiDocuments.endpoints'; + +/** + * Documents are a sub-resource of one API, keyed under that API's detail + * entry so a single invalidation of the parent covers every page, every filter + * and every open document. + * + * The parent id joins type and handle: a handle is unique only within its own + * type, so the handle alone could let a REST API and a WebSub API with the same + * handle share cache entries. + */ +export const apiDocumentKeys = createResourceKeys('apiDocuments'); + +export const apiDocumentParentId = (apiType: string, apiId: string): string => + `${apiType}/${apiId}`; + +/** Query shape the paged (infinite) list is keyed on — offset is the page param, not part of the key. */ +export type ApiDocumentPagesQuery = Omit; + +/** + * Offset of the page after `last`, or `undefined` once every document has been + * loaded. Reads `pagination.total` rather than guessing from a short page. + */ +export const nextDocumentsOffset = (last: ApiDocumentListResponse): number | undefined => { + const { offset, total } = last.pagination; + const next = offset + last.list.length; + return last.list.length > 0 && next < total ? next : undefined; +}; + +export const apiDocumentQueries = { + /** One explicit page — the overview's numbered pagination. */ + list: (org: OrgScope, apiType: string, apiId: string, query: ListApiDocumentsQuery = {}) => + queryOptions({ + queryKey: apiDocumentKeys.child(org, apiDocumentParentId(apiType, apiId), 'documents', query), + queryFn: ({ signal }) => listApiDocuments(apiType, apiId, query, { orgId: org, signal }), + staleTime: staleTimes.standard, + }), + + /** Pages appended on demand — the Documents page's "View more" list. */ + pages: (org: OrgScope, apiType: string, apiId: string, query: ApiDocumentPagesQuery = {}) => + infiniteQueryOptions({ + queryKey: apiDocumentKeys.child( + org, + apiDocumentParentId(apiType, apiId), + 'documentPages', + query + ), + queryFn: ({ pageParam, signal }) => + listApiDocuments(apiType, apiId, { ...query, offset: pageParam }, { orgId: org, signal }), + initialPageParam: 0, + getNextPageParam: nextDocumentsOffset, + staleTime: staleTimes.standard, + }), + + /** One document's metadata. */ + detail: (org: OrgScope, apiType: string, apiId: string, docId: string) => + queryOptions({ + queryKey: apiDocumentKeys.child(org, apiDocumentParentId(apiType, apiId), 'document', { + docId, + }), + queryFn: ({ signal }) => getApiDocument(apiType, apiId, docId, { orgId: org, signal }), + staleTime: staleTimes.standard, + }), + + /** + * One document's body. Keyed beside its metadata under the same parent, so + * the parent-level invalidation every write performs refreshes both. + */ + content: (org: OrgScope, apiType: string, apiId: string, docId: string) => + queryOptions({ + queryKey: apiDocumentKeys.child(org, apiDocumentParentId(apiType, apiId), 'documentContent', { + docId, + }), + queryFn: ({ signal }) => getApiDocumentContent(apiType, apiId, docId, { orgId: org, signal }), + staleTime: staleTimes.standard, + }), +}; diff --git a/portals/api-control-plane/src/api/resources/apiDocuments/index.ts b/portals/api-control-plane/src/api/resources/apiDocuments/index.ts new file mode 100644 index 0000000000..c3984564c3 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiDocuments/index.ts @@ -0,0 +1,50 @@ +/* + * 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. + */ + +/** + * Public surface of the API documents resource module. + * Import from `api/resources/apiDocuments` only; deeper imports skip scope binding and gating. + * @see ./apiDocuments.hooks.ts for the hook contract. + */ + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type { + ApiDocument, + ApiDocumentContent, + ApiDocumentListResponse, + ApiDocumentMetadata, + ApiDocumentType, + CreateApiDocumentBody, + ListApiDocumentsQuery, + UpdateApiDocumentBody, +} from './apiDocuments.endpoints'; + +export type { ApiDocumentPagesQuery } from './apiDocuments.queries'; + +// ─── Hooks ────────────────────────────────────────────────────────────────── + +export { + useApiDocument, + useApiDocumentContent, + useApiDocumentPages, + useApiDocuments, + useCreateApiDocument, + useDeleteApiDocument, + useUpdateApiDocument, +} from './apiDocuments.hooks'; diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.endpoints.test.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.endpoints.test.ts new file mode 100644 index 0000000000..2701c29636 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.endpoints.test.ts @@ -0,0 +1,194 @@ +/* + * 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 { http as mswHttp, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + accepts, + apiUrl, + failure, + noContent, + recorder, + type Recorder, +} from '../../../test/msw'; +import { server } from '../../../test/server'; +import { ApiError } from '../../core/errors'; +import { resetHttpClient } from '../../core/http'; +import { + deleteApiThumbnail, + getApiThumbnail, + upsertApiThumbnail, +} from './apiThumbnail.endpoints'; + +/** + * Contract tests for `/apis/{apiType}/{apiId}/thumbnail`. + * + * The thumbnail endpoint is unusual on three axes: + * 1. GET returns raw bytes (a Blob), not a JSON envelope. When no thumbnail + * is set, the server returns 204 No Content (not 404) so the browser + * DevTools stays free of red entries for APIs without a thumbnail. + * 2. 204 and 404 both resolve to `null` — the endpoint never throws for + * either "no thumbnail" status, letting the hook gate the UI without a + * try/catch at every call site. + * 3. PUT is multipart (image bytes), not JSON. jsdom can't serialise FormData + * onto the wire, so what we assert is that the Content-Type isn't labelled + * JSON and that a non-GET verb is used. + */ + +const PATH = '/apis/rest-api/orders-api/thumbnail'; +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +let requests: Recorder; + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); +}); + +describe('getApiThumbnail', () => { + it('GETs the thumbnail and labels the Blob with the sniffed content type', async () => { + server.use( + mswHttp.get(apiUrl(PATH), async ({ request }) => { + await requests.capture(request); + return new HttpResponse(PNG_BYTES, { + headers: { 'Content-Type': 'image/png' }, + }); + }) + ); + + const result = await getApiThumbnail('rest-api', 'orders-api'); + + expect(result).not.toBeNull(); + expect(result?.contentType).toBe('image/png'); + // A real Blob was returned (not undefined / null / a parsed JSON object). + // Byte-level equality isn't asserted here: jsdom's MSW<->axios Blob path + // is a known-flaky boundary for binary fidelity, and the contract this + // endpoint owns is "hand the caller a Blob + its content type", not "the + // bytes survive jsdom's wrapping". + expect(result?.blob).toBeInstanceOf(Blob); + + expect(requests.last()?.method).toBe('GET'); + expect(requests.last()?.url.pathname).toBe('/api/v0.9/apis/rest-api/orders-api/thumbnail'); + }); + + it('resolves 204 No Content to null (no thumbnail set)', async () => { + // Primary contract: server returns 204 when no thumbnail exists so the + // browser DevTools shows no red error entry. requestBlob maps 204 → null. + server.use( + mswHttp.get(apiUrl(PATH), () => new HttpResponse(null, { status: 204 })) + ); + + await expect(getApiThumbnail('rest-api', 'orders-api')).resolves.toBeNull(); + }); + + it('resolves 404 to null for backward compatibility', async () => { + // Older server versions return 404; the endpoint still catches it so a + // mixed deployment never breaks the listing page's initials fallback. + server.use(failure('get', PATH, 404, 'NOT_FOUND')); + + await expect(getApiThumbnail('rest-api', 'orders-api')).resolves.toBeNull(); + }); + + it('propagates non-404 errors as ApiError', async () => { + // 5xx isn't a steady state; it's a real failure that should surface to the + // UI so a toast fires. Collapsing it to null (as 404 does) would hide the + // problem. The error code collapses to CLIENT_MALFORMED_ERROR for blob + // responses — the body is a Blob and the generic envelope parser can't + // read it — so what we assert here is the HTTP status, which stays + // trustworthy regardless of body shape. + server.use(failure('get', PATH, 500, 'INTERNAL_ERROR')); + + await expect(getApiThumbnail('rest-api', 'orders-api')).rejects.toBeInstanceOf(ApiError); + await expect(getApiThumbnail('rest-api', 'orders-api')).rejects.toMatchObject({ + status: 500, + }); + }); + + it('URL-encodes the apiType and apiId path segments', async () => { + server.use( + mswHttp.get(apiUrl('/apis/:apiType/:apiId/thumbnail'), async ({ request }) => { + await requests.capture(request); + return new HttpResponse(PNG_BYTES, { headers: { 'Content-Type': 'image/png' } }); + }) + ); + + await getApiThumbnail('rest-api', 'a b/c'); + + expect(requests.last()?.url.pathname).toBe('/api/v0.9/apis/rest-api/a%20b%2Fc/thumbnail'); + }); +}); + +describe('upsertApiThumbnail', () => { + it('PUTs a non-JSON body — the file is not JSON-serialized', async () => { + server.use(noContent('put', PATH, { record: requests })); + + const file = new Blob([PNG_BYTES], { type: 'image/png' }); + await upsertApiThumbnail('rest-api', 'orders-api', file); + + const request = requests.last(); + expect(request?.method).toBe('PUT'); + // jsdom cannot serialize FormData onto the wire as real multipart, so we + // match the apiDocuments.endpoints.test.ts convention: the only assertion + // we can make reliably is that the Content-Type is NOT application/json — + // which is what a regression that forgot the FormData branch would set. + expect(request?.headers.get('content-type') ?? '').not.toContain('application/json'); + }); + + it('returns a resolved promise on 204 — no envelope expected', async () => { + server.use(noContent('put', PATH)); + + await expect( + upsertApiThumbnail('rest-api', 'orders-api', new Blob([PNG_BYTES], { type: 'image/png' })) + ).resolves.toBeUndefined(); + }); + + it('surfaces a 413 (too large) as ApiError without throwing a generic Error', async () => { + server.use(failure('put', PATH, 413, 'PAYLOAD_TOO_LARGE')); + + await expect( + upsertApiThumbnail('rest-api', 'orders-api', new Blob([PNG_BYTES], { type: 'image/png' })) + ).rejects.toMatchObject({ code: 'PAYLOAD_TOO_LARGE' }); + }); +}); + +describe('deleteApiThumbnail', () => { + it('DELETEs the resource and treats 204 as success', async () => { + server.use(noContent('delete', PATH, { record: requests })); + + await expect(deleteApiThumbnail('rest-api', 'orders-api')).resolves.toBeUndefined(); + + expect(requests.last()?.method).toBe('DELETE'); + expect(requests.last()?.url.pathname).toBe('/api/v0.9/apis/rest-api/orders-api/thumbnail'); + }); + + it('surfaces a 404 as ApiError (delete has no "steady state", unlike GET)', async () => { + // The GET resolves 404→null because a missing thumbnail is a normal view + // state; a delete of a missing row is a race the UI should see, so this + // one stays a thrown error. + server.use(failure('delete', PATH, 404, 'NOT_FOUND')); + + await expect(deleteApiThumbnail('rest-api', 'orders-api')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); +}); + +// Suppress the unused `accepts` import warning if the file ever shrinks past +// using it elsewhere — kept for parity with the other endpoint tests. +void accepts; diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.endpoints.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.endpoints.ts new file mode 100644 index 0000000000..242421e7d7 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.endpoints.ts @@ -0,0 +1,93 @@ +/* + * 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 { ApiError } from '../../core/errors'; +import { http, type BlobResponse, type RequestOptions } from '../../core/http'; +import type { PathOf } from '../../core/spec'; + +/** + * Transport layer for an artifact's singleton thumbnail + * (`/apis/{apiType}/{apiId}/thumbnail`). + * + * There's no POST — a thumbnail is one-per-API, so `PUT` is an upsert that + * creates or replaces atomically. `GET` returns the bytes as a `Blob`; the + * caller wraps it in a URL for an `` tag. `DELETE` removes the stored + * image and the UI falls back to the API's name-initials avatar. + * + * Content-type is sniffed server-side — the uploader's declared + * `Content-Type` and filename extension are ignored for the type decision. + */ + +type ApiTypeParam = PathOf<'GetAPIThumbnail'>['apiType']; + +/** `/apis/{apiType}/{apiId}/thumbnail` — URL-encoded, handles are user-supplied. */ +const resourcePath = (apiType: ApiTypeParam, apiId: string): string => + `/apis/${encodeURIComponent(apiType)}/${encodeURIComponent(apiId)}/thumbnail`; + +/** + * One API's stored thumbnail bytes, as a Blob plus the sniffed content type, + * or `null` when no thumbnail is set. + */ +export const getApiThumbnail = async ( + apiType: string, + apiId: string, + options?: RequestOptions +): Promise => { + try { + return await http.getBlob(resourcePath(apiType, apiId), { + ...options, + operationName: 'GetAPIThumbnail', + }); + } catch (err) { + // Status-based check (not `err.code === 'NOT_FOUND'`) because responseType: + // 'blob' hands the error body back as a Blob — the generic envelope parser + // can't inspect it, so `err.code` collapses to CLIENT_MALFORMED_ERROR. The + // HTTP status is still accurate, so branch on that. + if (err instanceof ApiError && err.isNotFound) return null; + throw err; + } +}; + +/** + * Creates or replaces the API's thumbnail. The body is a multipart form with + * a single `file` field; the server rejects anything that isn't JPEG or PNG. + */ +export const upsertApiThumbnail = async ( + apiType: string, + apiId: string, + file: Blob, + options?: RequestOptions +): Promise => { + const form = new FormData(); + form.append('file', file); + await http.put(resourcePath(apiType, apiId), form, { + ...options, + operationName: 'UpsertAPIThumbnail', + }); +}; + +export const deleteApiThumbnail = async ( + apiType: string, + apiId: string, + options?: RequestOptions +): Promise => { + await http.delete(resourcePath(apiType, apiId), { + ...options, + operationName: 'DeleteAPIThumbnail', + }); +}; diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.hooks.test.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.hooks.test.ts new file mode 100644 index 0000000000..df71d510bf --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.hooks.test.ts @@ -0,0 +1,224 @@ +/* + * 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 { waitFor } from '@testing-library/react'; +import { http as mswHttp, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + apiUrl, + noContent, + recorder, + type Recorder, +} from '../../../test/msw'; +import { renderApiHook, settle } from '../../../test/renderApiHook'; +import { server } from '../../../test/server'; +import { resetHttpClient, type BlobResponse } from '../../core/http'; +import { + useApiThumbnail, + useDeleteApiThumbnail, + useUpsertApiThumbnail, +} from './apiThumbnail.hooks'; +import { apiThumbnailQueries } from './apiThumbnail.queries'; + +/** + * Hook-layer tests for API thumbnails. + * + * The thumbnail hook has three behaviours a component test can't see on its + * own, so they live here: + * + * 1. The `` URL is a `blob:` URL built from the fetched Blob. The + * component only renders a string — it can't tell "the URL points at the + * right bytes" from "the URL points anywhere at all". Here we assert that + * a `blob:` URL is produced when a Blob is cached and that it is + * `URL.revokeObjectURL`'d on unmount, so the handle doesn't leak. + * 2. A 204 from the server resolves to a `null` cache entry and a `url` of + * `undefined` — not an error. This is what lets a listing page render + * initials fallback instead of 20 error toasts. + * 3. The delete mutation uses `setQueryData(key, null)` rather than + * `invalidateQueries`. A refetch after invalidation would return 204 and + * React Query would keep the previous blob on screen until a page refresh. + */ + +const API_TYPE = 'rest-api'; +const API_ID = 'orders-api'; +const PATH = `/apis/${API_TYPE}/${API_ID}/thumbnail`; +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +let requests: Recorder; +const createdObjectURLs: string[] = []; +const revokedObjectURLs: string[] = []; + +URL.createObjectURL = vi.fn((): string => { + const url = `blob:test/${createdObjectURLs.length}`; + createdObjectURLs.push(url); + return url; +}); +URL.revokeObjectURL = vi.fn((url: string) => { + revokedObjectURLs.push(url); +}); + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); + createdObjectURLs.length = 0; + revokedObjectURLs.length = 0; +}); + +const servePng = () => { + server.use( + mswHttp.get(apiUrl(PATH), async ({ request }) => { + await requests.capture(request); + return new HttpResponse(PNG_BYTES, { headers: { 'Content-Type': 'image/png' } }); + }) + ); +}; + +const serveNoThumbnail = () => { + server.use( + mswHttp.get(apiUrl(PATH), async ({ request }) => { + await requests.capture(request); + return new HttpResponse(null, { status: 204 }); + }) + ); +}; + +describe('useApiThumbnail — fetch and lifecycle', () => { + it('does not fetch while the apiId is unknown', async () => { + servePng(); + + renderApiHook(() => useApiThumbnail(API_TYPE, undefined)); + await settle(); + + expect(requests.count()).toBe(0); + }); + + it('builds a blob: URL from the fetched Blob', async () => { + servePng(); + + const { result } = renderApiHook(() => useApiThumbnail(API_TYPE, API_ID)); + + await waitFor(() => expect(result.current.url).toBeDefined()); + expect(result.current.url).toMatch(/^blob:/); + expect(createdObjectURLs).toHaveLength(1); + }); + + it('releases the blob: URL on unmount so the handle does not leak', async () => { + servePng(); + + const { result, unmount } = renderApiHook(() => useApiThumbnail(API_TYPE, API_ID)); + await waitFor(() => expect(result.current.url).toBeDefined()); + const createdUrl = result.current.url; + + unmount(); + // The hook defers revocation via `setTimeout(0)` so a quick remount can + // reuse the same URL — wait a beat for that microtask to fire before + // asserting the revoke actually happened. + await settle(); + + // The URL built for this component must have been revoked at unmount. + // Without this check, a long-lived listing page that mounts and unmounts + // 20 thumbnail avatars per filter would accumulate blob URLs the GC + // can't reclaim because the browser keeps the backing bytes alive. + expect(revokedObjectURLs).toContain(createdUrl); + }); + + it('surfaces 204 (no thumbnail) as a null url, no error', async () => { + // The listing page uses `url` to decide between an `` and an initials + // fallback. A thrown error on this hook would break every API card with + // no custom thumbnail — which is the common case. + serveNoThumbnail(); + + const { result } = renderApiHook(() => useApiThumbnail(API_TYPE, API_ID)); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(result.current.url).toBeUndefined(); + expect(result.current.error).toBeNull(); + }); +}); + +describe('useUpsertApiThumbnail', () => { + it('invalidates the thumbnail query on success so a live refetches', async () => { + server.use(noContent('put', PATH)); + + const { result, queryClient, org } = renderApiHook(() => useUpsertApiThumbnail()); + const key = apiThumbnailQueries.blob(org, API_TYPE, API_ID).queryKey; + // The tagged queryKey's `Updater` + // doesn't narrow a plain object literal correctly in current @tanstack/ + // react-query typings. Casting the typed seed through `as BlobResponse` + // keeps the runtime payload identical while satisfying the signature. + const seed: BlobResponse = { blob: new Blob([PNG_BYTES]), contentType: 'image/png' }; + queryClient.setQueryData(key, seed as BlobResponse); + + result.current.mutate({ + apiType: API_TYPE, + apiId: API_ID, + file: new Blob([PNG_BYTES], { type: 'image/png' }), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Invalidation (not setQueryData(null)) is correct for upsert: a GET + // following the invalidate will return the newly-stored bytes, which the + // hook needs so the img switches to the new thumbnail. + await waitFor(() => expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)); + }); +}); + +describe('useDeleteApiThumbnail', () => { + it('flips the cached blob to null without triggering a refetch', async () => { + // The subtle one. A naive invalidate-then-refetch would send a GET that + // returns 404; React Query treats a thrown-from-queryFn as an error and + // keeps the previous `data` on screen (the user has to refresh the page + // for the stale thumbnail to disappear). Setting the cache value to + // `null` directly flips every live `` to the initials fallback on + // the next render with zero extra network calls. + server.use(noContent('delete', PATH)); + // No GET handler is registered. If the hook tried to refetch, the test + // would fail with onUnhandledRequest: 'error'. + + const { result, queryClient, org } = renderApiHook(() => useDeleteApiThumbnail()); + const key = apiThumbnailQueries.blob(org, API_TYPE, API_ID).queryKey; + const seed: BlobResponse = { blob: new Blob([PNG_BYTES]), contentType: 'image/png' }; + queryClient.setQueryData(key, seed as BlobResponse); + + result.current.mutate({ apiType: API_TYPE, apiId: API_ID }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(queryClient.getQueryData(key)).toBeNull(); + }); + + it('leaves another API’s cached thumbnail untouched', async () => { + // A delete key is `[... , apiType/apiId , 'blob']`. If a regression wrote + // to a broader key (e.g. the resource root), every API's thumbnail cache + // in that tenant would flip to null at once. + server.use(noContent('delete', PATH)); + + const { result, queryClient, org } = renderApiHook(() => useDeleteApiThumbnail()); + const targetKey = apiThumbnailQueries.blob(org, API_TYPE, API_ID).queryKey; + const otherKey = apiThumbnailQueries.blob(org, API_TYPE, 'other-api').queryKey; + const seed: BlobResponse = { blob: new Blob([PNG_BYTES]), contentType: 'image/png' }; + queryClient.setQueryData(targetKey, seed as BlobResponse); + queryClient.setQueryData(otherKey, seed as BlobResponse); + + result.current.mutate({ apiType: API_TYPE, apiId: API_ID }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(queryClient.getQueryData(targetKey)).toBeNull(); + expect(queryClient.getQueryData(otherKey)).not.toBeNull(); + }); +}); diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.hooks.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.hooks.ts new file mode 100644 index 0000000000..e5c8ec27ea --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.hooks.ts @@ -0,0 +1,174 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { ApiError } from '../../core/errors'; +import { useApiScope } from '../../core/scope'; +import { + deleteApiThumbnail, + getApiThumbnail, + upsertApiThumbnail, +} from './apiThumbnail.endpoints'; +import { apiThumbnailKeys, apiThumbnailParentId, apiThumbnailQueries } from './apiThumbnail.queries'; + +/** + * Public hook surface for API thumbnails. + * + * The GET hook returns a browser `objectURL` for an `` — the raw Blob + * is cached by React Query so multiple ``s targeting the same API share + * one fetch. The URL is revoked on unmount / blob swap to avoid leaking the + * `blob:` handle. "No thumbnail set" is resolved at the endpoint to a `null` + * successful value (not an error), so the UI can distinguish it from a real + * failure and the delete mutation can push the empty state into the cache + * directly for an instant UI flip. + */ + +/** + * Shared, ref-counted `blob:` URLs keyed by Blob. + * + * Every avatar showing the same cached thumbnail reuses one URL, and the URL + * is revoked only once no mounted component holds it. The revoke is deferred + * so an immediate remount (StrictMode's mount → cleanup → mount, or a quick + * route change between pages that both show the avatar) re-acquires the same + * URL instead of rendering a revoked one, which made the `` fail and fall + * back to initials until a full refresh. + */ +const objectUrls = new Map(); + +const objectUrlFor = (blob: Blob): string => { + let entry = objectUrls.get(blob); + if (!entry) { + entry = { url: URL.createObjectURL(blob), refs: 0 }; + objectUrls.set(blob, entry); + } + return entry.url; +}; + +const retainObjectUrl = (blob: Blob): (() => void) => { + objectUrlFor(blob); + objectUrls.get(blob)!.refs += 1; + return () => { + const entry = objectUrls.get(blob); + if (!entry) return; + entry.refs -= 1; + setTimeout(() => { + const current = objectUrls.get(blob); + if (current && current.refs <= 0) { + URL.revokeObjectURL(current.url); + objectUrls.delete(blob); + } + }, 0); + }; +}; + +type Overrides = { orgId?: string }; + +type UseApiThumbnailResult = { + /** `blob:` URL for an `` tag. `undefined` while loading, when no thumbnail exists, or on a real error. */ + url: string | undefined; + /** True on first fetch (branch here, not on `isLoading`, since the query is scope-gated). */ + isPending: boolean; + /** A real error. "No thumbnail" is a successful `null` value, not an error. */ + error: ApiError | null; +}; + +export const useApiThumbnail = ( + apiType: string, + apiId: string | undefined, + overrides: Overrides = {} +): UseApiThumbnailResult => { + const { org } = useApiScope(overrides); + + const query = useQuery({ + ...apiThumbnailQueries.blob(org!, apiType, apiId!), + enabled: Boolean(org && apiId), + }); + + const blob = query.data?.blob; + // Create the URL inside the effect, not during render. Calling + // `objectUrlFor(blob)` in render would store a shared-cache entry with + // `refs: 0`; if the render is discarded before commit (concurrent mode, + // Suspense / error interruption, blob swap), the effect never increments + // the ref count, no revoke timer is scheduled, and the `blob:` URL leaks + // its backing bytes. Keeping URL creation + retain / release inside the + // same commit-phase effect ties the URL's lifetime to a mounted consumer. + const [url, setUrl] = useState(undefined); + useEffect(() => { + if (!blob) { + setUrl(undefined); + return; + } + setUrl(objectUrlFor(blob)); + return retainObjectUrl(blob); + }, [blob]); + + return { url, isPending: query.isPending, error: query.error as ApiError | null }; +}; + +/** + * Invalidates one API's thumbnail cache so every live `` on the page + * refetches on next render. + */ +const useInvalidateApiThumbnail = (overrides: Overrides) => { + const queryClient = useQueryClient(); + const { org } = useApiScope(overrides); + + return (apiType: string, apiId: string) => { + if (!org) return; + void queryClient.invalidateQueries({ + queryKey: apiThumbnailKeys.detail(org, apiThumbnailParentId(apiType, apiId)), + }); + }; +}; + +type ParentArgs = { apiType: string; apiId: string }; + +export const useUpsertApiThumbnail = (overrides: Overrides = {}) => { + const { orgId } = useApiScope(overrides); + const invalidate = useInvalidateApiThumbnail(overrides); + + return useMutation({ + mutationFn: ({ apiType, apiId, file }) => + upsertApiThumbnail(apiType, apiId, file, { orgId }), + onSuccess: (_data, { apiType, apiId }) => invalidate(apiType, apiId), + }); +}; + +export const useDeleteApiThumbnail = (overrides: Overrides = {}) => { + const queryClient = useQueryClient(); + const { org, orgId } = useApiScope(overrides); + + return useMutation({ + mutationFn: ({ apiType, apiId }) => deleteApiThumbnail(apiType, apiId, { orgId }), + onSuccess: (_data, { apiType, apiId }) => { + // Push the "no thumbnail" state into the cache directly instead of + // invalidating-and-refetching. Invalidation would trigger a GET that + // returns 204 + if (!org) return; + queryClient.setQueryData( + apiThumbnailQueries.blob(org, apiType, apiId).queryKey, + null + ); + }, + }); +}; + +// Re-exports kept for parity with the file structure other resources use. +export { getApiThumbnail }; diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.queries.test.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.queries.test.ts new file mode 100644 index 0000000000..ff4db515f8 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.queries.test.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 { describe, expect, it } from 'vitest'; + +import { orgScope } from '../../core/queryKeys'; +import { apiThumbnailKeys, apiThumbnailParentId, apiThumbnailQueries } from './apiThumbnail.queries'; + +/** + * Query-key tests for the thumbnail singleton. The hook test proves the blob + * URL lifecycle; this file guards the key shape itself so a listing page + * sharing one cache entry per API never breaks silently. + */ + +const org = orgScope('acme')!; + +describe('apiThumbnailParentId', () => { + it('joins apiType and apiId so two kinds with the same handle do not share a thumbnail', () => { + expect(apiThumbnailParentId('rest-api', 'orders')).toBe('rest-api/orders'); + expect(apiThumbnailParentId('rest-api', 'orders')).not.toBe( + apiThumbnailParentId('mcp', 'orders') + ); + }); +}); + +describe('apiThumbnailQueries key shape', () => { + it('nests the blob key under the API’s parent id', () => { + const parentPrefix = apiThumbnailKeys.detail(org, apiThumbnailParentId('rest-api', 'orders')); + const blob = apiThumbnailQueries.blob(org, 'rest-api', 'orders').queryKey; + expect(blob.slice(0, parentPrefix.length)).toEqual([...parentPrefix]); + }); + + it('produces equal keys for the same API so the listing and detail page share one network call', () => { + const a = apiThumbnailQueries.blob(org, 'rest-api', 'orders').queryKey; + const b = apiThumbnailQueries.blob(org, 'rest-api', 'orders').queryKey; + expect(a).toEqual(b); + }); + + it('produces different keys per API so one card’s delete cannot flip every other card to null', () => { + const a = apiThumbnailQueries.blob(org, 'rest-api', 'orders').queryKey; + const b = apiThumbnailQueries.blob(org, 'rest-api', 'returns').queryKey; + expect(a).not.toEqual(b); + }); + + it('produces different keys per apiType so a REST and MCP API with the same handle are isolated', () => { + const rest = apiThumbnailQueries.blob(org, 'rest-api', 'orders').queryKey; + const mcp = apiThumbnailQueries.blob(org, 'mcp', 'orders').queryKey; + expect(rest).not.toEqual(mcp); + }); + + it('disables retry — a 204 is a steady state, not a transient failure', () => { + // Documented in the file: retrying the blob fetch after a 204 would flood + // the network on every API with no thumbnail (the common case). + expect(apiThumbnailQueries.blob(org, 'rest-api', 'orders').retry).toBe(false); + }); +}); diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.queries.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.queries.ts new file mode 100644 index 0000000000..8d32730b9f --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/apiThumbnail.queries.ts @@ -0,0 +1,47 @@ +/* + * 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 { queryOptions } from '@tanstack/react-query'; + +import { staleTimes } from '../../core/queryClient'; +import { createResourceKeys, type OrgScope } from '../../core/queryKeys'; +import { getApiThumbnail } from './apiThumbnail.endpoints'; + +/** + * Thumbnails are a singleton per artifact — one row keyed on the API's + * (type, handle). Caching the fetched Blob under this key means `` tags + * on the API detail page and the API listing share one network call per API, + * and a PUT/DELETE invalidation refreshes every live `` at once. + */ +export const apiThumbnailKeys = createResourceKeys('apiThumbnail'); + +export const apiThumbnailParentId = (apiType: string, apiId: string): string => + `${apiType}/${apiId}`; + +export const apiThumbnailQueries = { + /** The stored thumbnail Blob + sniffed content type. 204 No Content is the normal "not set" state. */ + blob: (org: OrgScope, apiType: string, apiId: string) => + queryOptions({ + queryKey: apiThumbnailKeys.child(org, apiThumbnailParentId(apiType, apiId), 'blob'), + queryFn: ({ signal }) => getApiThumbnail(apiType, apiId, { orgId: org, signal }), + staleTime: staleTimes.standard, + // A missing thumbnail is a steady state, not a transient failure — one + // 204 doesn't mean the next request will succeed. + retry: false, + }), +}; diff --git a/portals/api-control-plane/src/api/resources/apiThumbnail/index.ts b/portals/api-control-plane/src/api/resources/apiThumbnail/index.ts new file mode 100644 index 0000000000..2ebbc5e071 --- /dev/null +++ b/portals/api-control-plane/src/api/resources/apiThumbnail/index.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * Public surface of the API thumbnail resource module. + * Import from `api/resources/apiThumbnail` only; deeper imports skip scope binding and gating. + * @see ./apiThumbnail.hooks.ts for the hook contract. + */ + +export { + useApiThumbnail, + useDeleteApiThumbnail, + useUpsertApiThumbnail, +} from './apiThumbnail.hooks'; diff --git a/portals/api-control-plane/src/components/MarkdownView/MarkdownView.test.tsx b/portals/api-control-plane/src/components/MarkdownView/MarkdownView.test.tsx new file mode 100644 index 0000000000..3c6f5ce393 --- /dev/null +++ b/portals/api-control-plane/src/components/MarkdownView/MarkdownView.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 { describe, expect, it } from 'vitest'; + +import { renderWithProviders, screen } from '@/test/utils'; +import { MarkdownView } from './MarkdownView'; + +describe('MarkdownView', () => { + it('renders document structure as elements', () => { + renderWithProviders(); + + expect(screen.getByRole('heading', { name: 'Guide' })).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'docs' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByRole('listitem')).toHaveTextContent('step'); + }); + + it('shows embedded HTML as text instead of injecting it', () => { + const { container } = renderWithProviders( + '} /> + ); + + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('script')).toBeNull(); + expect(screen.getByText(/')).toEqual([ + { kind: 'paragraph', children: [{ kind: 'text', text: '' }] }, + ]); + }); + + it('returns no blocks for blank input', () => { + expect(parseMarkdown(' \n\n ')).toEqual([]); + }); +}); + +describe('parseInline', () => { + it('parses code, emphasis and links', () => { + expect(parseInline('Send `GET` to *the* [docs](https://example.com)')).toEqual([ + { kind: 'text', text: 'Send ' }, + { kind: 'code', text: 'GET' }, + { kind: 'text', text: ' to ' }, + { kind: 'em', children: [{ kind: 'text', text: 'the' }] }, + { kind: 'text', text: ' ' }, + { kind: 'link', href: 'https://example.com', children: [{ kind: 'text', text: 'docs' }] }, + ]); + }); + + it('does not treat snake_case as emphasis', () => { + expect(parseInline('use reading_list_api here')).toEqual([ + { kind: 'text', text: 'use reading_list_api here' }, + ]); + }); + + it('drops an unsafe link target but keeps its text', () => { + expect(parseInline('[click](javascript:alert%281%29)')).toEqual([{ kind: 'text', text: 'click' }]); + }); +}); + +describe('safeHref', () => { + it.each(['https://example.com', 'http://example.com', 'mailto:team@example.com', '/relative', '#anchor'])( + 'allows %s', + (href) => { + expect(safeHref(href)).toBe(href); + } + ); + + it.each(['javascript:alert(1)', 'JAVA\tSCRIPT:alert(1)', 'data:text/html,x', 'vbscript:x', '//evil.example'])( + 'rejects %s', + (href) => { + expect(safeHref(href)).toBeUndefined(); + } + ); +}); diff --git a/portals/api-control-plane/src/components/MarkdownView/markdown.ts b/portals/api-control-plane/src/components/MarkdownView/markdown.ts new file mode 100644 index 0000000000..9344e49d27 --- /dev/null +++ b/portals/api-control-plane/src/components/MarkdownView/markdown.ts @@ -0,0 +1,299 @@ +/* + * 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. + */ + +/** + * A deliberately small Markdown parser for API documents. + * + * Why not a library: a Markdown-to-HTML converter passes raw inline HTML + * through unchanged, so its output would need sanitizing and then rendering via + * `dangerouslySetInnerHTML` (`.claude/rules/js-output-encoding-xss.md`). This + * parser instead produces a plain tree that `MarkdownView` renders as React + * elements, so every piece of document text reaches the DOM as an escaped text + * node and raw HTML in a document is shown as literal text, never executed. + * + * Supported: ATX headings, paragraphs, fenced and indented code blocks, + * bulleted and numbered lists, block quotes, horizontal rules, and inline code, + * bold, italic, links and hard line breaks. Anything else renders as text. + */ + +export type MarkdownInline = + | { kind: 'text'; text: string } + | { kind: 'code'; text: string } + | { kind: 'strong'; children: MarkdownInline[] } + | { kind: 'em'; children: MarkdownInline[] } + | { kind: 'link'; href: string; children: MarkdownInline[] } + | { kind: 'break' }; + +export type MarkdownBlock = + | { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; children: MarkdownInline[] } + | { kind: 'paragraph'; children: MarkdownInline[] } + | { kind: 'code'; language?: string; text: string } + | { kind: 'list'; ordered: boolean; start: number; items: MarkdownInline[][] } + | { kind: 'quote'; children: MarkdownBlock[] } + | { kind: 'rule' }; + +const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+-]*)/; +const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/; +const RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/; +const BULLET = /^ {0,3}[-*+]\s+(.*)$/; +const ORDERED = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/; +const QUOTE = /^ {0,3}>\s?(.*)$/; +const INDENTED_CODE = /^( {4}|\t)(.*)$/; + +/** + * Link targets allowed through. Anything else — `javascript:`, `data:`, + * `vbscript:` and friends — is dropped and the link text rendered on its own. + * Relative targets are allowed: they resolve against the console's own origin. + */ +export const safeHref = (raw: string): string | undefined => { + // Browsers ignore control characters and whitespace inside a scheme + // ("java\tscript:"), so they are stripped before the scheme is judged. + // eslint-disable-next-line no-control-regex + const href = raw.trim().replace(/[\u0000-\u001F\u007F\s]+/g, ''); + if (!href) return undefined; + if (/^(https?:|mailto:)/i.test(href)) return href; + if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return undefined; + const normalised = href.replace(/\\/g, '/'); + if (normalised.startsWith('//')) return undefined; + return href; +}; + +/** Parses inline spans: code, links, bold, italic, line breaks. */ +export function parseInline(source: string): MarkdownInline[] { + const out: MarkdownInline[] = []; + let buffer = ''; + const flush = () => { + if (buffer) out.push({ kind: 'text', text: buffer }); + buffer = ''; + }; + + let i = 0; + while (i < source.length) { + const char = source[i]; + const rest = source.slice(i); + + // Backslash escape: the next punctuation character is literal. + if (char === '\\' && i + 1 < source.length && /[\\`*_{}[\]()#+\-.!>~|]/.test(source[i + 1])) { + buffer += source[i + 1]; + i += 2; + continue; + } + + if (char === '\n') { + flush(); + out.push({ kind: 'break' }); + i += 1; + continue; + } + + if (char === '`') { + const ticks = /^`+/.exec(rest)![0]; + const end = source.indexOf(ticks, i + ticks.length); + if (end !== -1) { + flush(); + out.push({ kind: 'code', text: source.slice(i + ticks.length, end).trim() }); + i = end + ticks.length; + continue; + } + } + + if (char === '[') { + const link = /^\[([^\]]*)\]\(\s*]*)>?(?:\s+"[^"]*")?\s*\)/.exec(rest); + if (link) { + flush(); + const children = parseInline(link[1]); + const href = safeHref(link[2]); + if (href) out.push({ kind: 'link', href, children }); + else out.push(...children); + i += link[0].length; + continue; + } + } + + if (char === '<') { + const auto = /^<((?:https?:\/\/|mailto:)[^\s<>]+)>/i.exec(rest); + if (auto) { + flush(); + const href = safeHref(auto[1]); + out.push( + href + ? { kind: 'link', href, children: [{ kind: 'text', text: auto[1] }] } + : { kind: 'text', text: auto[0] } + ); + i += auto[0].length; + continue; + } + } + + if (char === '*' || char === '_') { + const strong = new RegExp(`^\\${char}{2}(?=\\S)([\\s\\S]*?\\S)\\${char}{2}`).exec(rest); + if (strong) { + flush(); + out.push({ kind: 'strong', children: parseInline(strong[1]) }); + i += strong[0].length; + continue; + } + // `_` only opens emphasis at a word boundary, so snake_case stays intact. + const boundary = char === '*' || i === 0 || /[\s([{]/.test(source[i - 1]); + const em = new RegExp(`^\\${char}(?=\\S)([\\s\\S]*?\\S)\\${char}(?!\\${char})`).exec(rest); + if (boundary && em && (char === '*' || !/\w/.test(source[i + em[0].length] ?? ''))) { + flush(); + out.push({ kind: 'em', children: parseInline(em[1]) }); + i += em[0].length; + continue; + } + } + + buffer += char; + i += 1; + } + flush(); + return out; +} + +/** Joins a paragraph's lines: a trailing double space or backslash is a hard break, else a space. */ +const joinParagraph = (lines: string[]): string => + lines + .map((line, index) => { + if (index === lines.length - 1) return line.trim(); + if (/( {2,}|\\)$/.test(line)) return `${line.replace(/( {2,}|\\)$/, '').trim()}\n`; + return `${line.trim()} `; + }) + .join(''); + +const startsBlock = (line: string): boolean => + FENCE.test(line) || + HEADING.test(line) || + RULE.test(line) || + BULLET.test(line) || + ORDERED.test(line) || + QUOTE.test(line); + +const MAX_QUOTE_DEPTH = 20; + +/** Parses a Markdown document into blocks. Never throws: unknown syntax becomes text. */ +export function parseMarkdown(source: string, depth = 0): MarkdownBlock[] { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const blocks: MarkdownBlock[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.trim() === '') { + i += 1; + continue; + } + + const fence = FENCE.exec(line); + if (fence) { + const marker = fence[1]; + const closing = new RegExp( + `^ {0,3}${marker[0] === '`' ? '`' : '~'}{${marker.length},}\\s*$`, + ); + const body: string[] = []; + i += 1; + while (i < lines.length && !closing.test(lines[i])) { + body.push(lines[i]); + i += 1; + } + i += 1; // closing fence (or end of document) + blocks.push({ kind: 'code', language: fence[2] || undefined, text: body.join('\n') }); + continue; + } + + const heading = HEADING.exec(line); + if (heading) { + blocks.push({ + kind: 'heading', + level: heading[1].length as 1 | 2 | 3 | 4 | 5 | 6, + children: parseInline(heading[2]), + }); + i += 1; + continue; + } + + if (RULE.test(line)) { + blocks.push({ kind: 'rule' }); + i += 1; + continue; + } + + if (QUOTE.test(line)) { + const body: string[] = []; + while (i < lines.length && QUOTE.test(lines[i])) { + body.push(QUOTE.exec(lines[i])![1]); + i += 1; + } + if (depth >= MAX_QUOTE_DEPTH) { + // Past the nesting cap: render the raw text instead of recursing. + blocks.push({ kind: 'paragraph', children: parseInline(body.join('\n')) }); + } else { + blocks.push({ kind: 'quote', children: parseMarkdown(body.join('\n'), depth + 1) }); + } + continue; + } + + const bullet = BULLET.test(line); + const ordered = ORDERED.exec(line); + if (bullet || ordered) { + const pattern = bullet ? BULLET : ORDERED; + const items: string[][] = []; + while (i < lines.length) { + const match = pattern.exec(lines[i]); + if (match) { + items.push([bullet ? match[1] : match[2]]); + } else if (lines[i].trim() !== '' && /^\s+\S/.test(lines[i]) && items.length > 0) { + // An indented continuation line belongs to the current item. + items[items.length - 1].push(lines[i]); + } else { + break; + } + i += 1; + } + blocks.push({ + kind: 'list', + ordered: Boolean(ordered), + start: ordered ? Number(ordered[1]) : 1, + items: items.map((item) => parseInline(joinParagraph(item))), + }); + continue; + } + + if (INDENTED_CODE.test(line)) { + const body: string[] = []; + while (i < lines.length && (INDENTED_CODE.test(lines[i]) || lines[i].trim() === '')) { + body.push(INDENTED_CODE.exec(lines[i])?.[2] ?? ''); + i += 1; + } + while (body.length && body[body.length - 1] === '') body.pop(); + blocks.push({ kind: 'code', text: body.join('\n') }); + continue; + } + + const paragraph: string[] = []; + while (i < lines.length && lines[i].trim() !== '' && (paragraph.length === 0 || !startsBlock(lines[i]))) { + paragraph.push(lines[i]); + i += 1; + } + blocks.push({ kind: 'paragraph', children: parseInline(joinParagraph(paragraph)) }); + } + + return blocks; +} + diff --git a/portals/api-control-plane/src/components/illustrations/DocumentsIllustration.tsx b/portals/api-control-plane/src/components/illustrations/DocumentsIllustration.tsx new file mode 100644 index 0000000000..79dded3ad5 --- /dev/null +++ b/portals/api-control-plane/src/components/illustrations/DocumentsIllustration.tsx @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ColorSchemeSVG } from '@wso2/oxygen-ui'; + +/** + * A small stack of an API's documents — a guide open on top of the others, + * with the type tabs they are grouped under — for the first-run `EmptyState` + * on Develop › Documents. Drawn in the same shapes and colour roles as + * `GatewayIllustration`, `MonitorIllustration` and `ProjectFolderIllustration` + * so every empty page in the console reads as one family. + * + * Purely decorative: the `EmptyState` heading carries the meaning, so it is + * hidden from assistive technology. + */ +export function DocumentsIllustration() { + return ( + + {/* The documents behind, fanned out. */} + + + + {/* The open guide: a dark slab with its page filled with the page + background, so it stays legible whichever way the slab resolves. */} + + + + + + + + + + + {/* The type tabs the documents are filed under. */} + + + + + + {/* The surface it rests on. */} + + + ); +} diff --git a/portals/api-control-plane/src/i18n/messages/en.json b/portals/api-control-plane/src/i18n/messages/en.json index cc259d970f..6b0cc985d5 100644 --- a/portals/api-control-plane/src/i18n/messages/en.json +++ b/portals/api-control-plane/src/i18n/messages/en.json @@ -788,6 +788,33 @@ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.unknownCreator": { "defaultMessage": "—" }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.changeThumbnail": { + "defaultMessage": "Change thumbnail" + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.confirmDeleteButton": { + "defaultMessage": "Remove" + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.confirmDeleteMessage": { + "defaultMessage": "Are you sure you want to delete the thumbnail of this API?" + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.confirmDeleteTitle": { + "defaultMessage": "Remove API thumbnail" + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.deleteSuccess": { + "defaultMessage": "Thumbnail removed." + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.errorSize": { + "defaultMessage": "The image must be 1 MB or smaller." + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.errorType": { + "defaultMessage": "Only JPEG and PNG images are supported." + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.removeThumbnail": { + "defaultMessage": "Remove thumbnail" + }, + "apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.uploadSuccess": { + "defaultMessage": "Thumbnail updated." + }, "apiControlPlane.pages.appShell.appShellPages.apis.create.components.ApiDesignerBanner.action": { "defaultMessage": "Open API Designer", "description": "Button opening the API Designer VS Code extension listing in a new tab. \"API Designer\" is a product name — leave it untranslated." @@ -1097,15 +1124,29 @@ "apiControlPlane.pages.appShell.appShellPages.apis.overview.DeployedGatewaysPanel.title": { "defaultMessage": "Deployed gateways" }, - "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.description": { - "defaultMessage": "Guides, references, and specifications published with this API." + "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.create": { + "defaultMessage": "Create Document", + "description": "Opens the form for a new API document. Shown when the API has none." + }, + "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.empty": { + "defaultMessage": "No Documents available for this API" + }, + "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.loadError": { + "defaultMessage": "Unable to load documents." + }, + "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.loading": { + "defaultMessage": "Loading documents…" }, "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.title": { "defaultMessage": "Documents" }, "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.updated": { - "defaultMessage": "2 days ago", - "description": "Temporary relative update time for demo document data." + "defaultMessage": "Updated {when}", + "description": "{when} is a relative time, e.g. \"2 days ago\"." + }, + "apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.viewMore": { + "defaultMessage": "View More", + "description": "Opens the API’s Documents page to see every document." }, "apiControlPlane.pages.appShell.appShellPages.apis.overview.EndpointsPanel.cancel": { "defaultMessage": "Cancel" @@ -1423,12 +1464,6 @@ "defaultMessage": "Select Deployment to Restore", "description": "Heading of the drawer for picking an earlier deployment to put back in service." }, - "apiControlPlane.pages.appShell.appShellPages.develop.DocumentsTab.detail": { - "defaultMessage": "You will be able to publish guides, references, and release notes alongside the API so consumers can read them in the Developer Portal." - }, - "apiControlPlane.pages.appShell.appShellPages.develop.DocumentsTab.feature": { - "defaultMessage": "Documents for this API" - }, "apiControlPlane.pages.appShell.appShellPages.develop.SaveBar.cancel": { "defaultMessage": "Cancel" }, @@ -1444,6 +1479,221 @@ "defaultMessage": "You have unsaved changes", "description": "Shown on the save bar while the panel has edits that have not been saved yet." }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.back": { + "defaultMessage": "Back to documents" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.cancel": { + "defaultMessage": "Cancel" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentHint": { + "defaultMessage": "Markdown supported: headings, lists, links, code blocks and quotes." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentLabel": { + "defaultMessage": "Content" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.contentPlaceholder": { + "defaultMessage": "# Write your document in Markdown…" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.create": { + "defaultMessage": "Create", + "description": "Submits the new document. Verb." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.createSubtitle": { + "defaultMessage": "Choose a type, name it and write the content in Markdown." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.createTitle": { + "defaultMessage": "Create Document" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.created": { + "defaultMessage": "Created \"{name}\"." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeInvalid": { + "defaultMessage": "Use only letters, numbers, spaces, hyphens and underscores." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeLabel": { + "defaultMessage": "Custom type", + "description": "Label for the free-text type name shown when the document type is \"Other\"." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypePlaceholder": { + "defaultMessage": "e.g. Changelog" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeReserved": { + "defaultMessage": "This is a reserved document type." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.customTypeTooLong": { + "defaultMessage": "Keep under {max} bytes (non-Latin letters count as more than one)." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.editSubtitle": { + "defaultMessage": "Update the type, name or Markdown content of this document." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.editTitle": { + "defaultMessage": "Edit Document" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.layout": { + "defaultMessage": "Editor layout", + "description": "Accessible name of the Source / Split / Preview switch." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leaveMessage": { + "defaultMessage": "You have unsaved changes. Are you sure you want to leave?" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leaveNo": { + "defaultMessage": "No", + "description": "Answers \"Are you sure you want to leave?\": stays on the form with the changes kept." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leaveTitle": { + "defaultMessage": "Unsaved changes", + "description": "Title of the dialog asking whether to leave the document form without saving." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.leaveYes": { + "defaultMessage": "Yes", + "description": "Answers \"Are you sure you want to leave?\": leaves the form and discards unsaved changes." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.loadError": { + "defaultMessage": "Unable to load this document for editing." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.loading": { + "defaultMessage": "Loading document" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.markdownPane": { + "defaultMessage": "Markdown" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameExists": { + "defaultMessage": "A document with this name already exists for this API." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.nameLabel": { + "defaultMessage": "Name", + "description": "Label for the document name field. Noun." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.namePlaceholder": { + "defaultMessage": "e.g. Getting started" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.notEditable": { + "defaultMessage": "This document’s format can’t be edited here." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.preview": { + "defaultMessage": "Preview" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.previewEmpty": { + "defaultMessage": "Nothing to preview yet." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.save": { + "defaultMessage": "Save", + "description": "Saves changes to the document. Verb." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.saved": { + "defaultMessage": "Saved \"{name}\"." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.saving": { + "defaultMessage": "Saving…" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.source": { + "defaultMessage": "Source" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.split": { + "defaultMessage": "Split", + "description": "Editor layout showing the Markdown source and its preview side by side." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.tooLarge": { + "defaultMessage": "The document is too large to save." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentEditor.typeLabel": { + "defaultMessage": "Document type" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.heading": { + "defaultMessage": "All documents" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.loadingMore": { + "defaultMessage": "Loading documents…" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.toggleGroup": { + "defaultMessage": "{type} ({count})", + "description": "Accessible name of a document-type group header that expands or collapses the group." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.total": { + "defaultMessage": "{count, plural, one {# document} other {# documents}}" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentList.updated": { + "defaultMessage": "Updated {when}", + "description": "Secondary line of a document in the list. {when} is a relative time, e.g. \"2 days ago\"." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.howTo": { + "defaultMessage": "How To", + "description": "Document type: a step-by-step guide." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.other": { + "defaultMessage": "Other", + "description": "Document type: anything that fits no other type." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.publicForum": { + "defaultMessage": "Public Forum", + "description": "Document type: a link to or notes about a public community forum." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.sampleSdk": { + "defaultMessage": "Samples & SDK", + "description": "Document type: code samples and client SDKs." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentTypes.supportForum": { + "defaultMessage": "Support Forum", + "description": "Document type: a link to or notes about a support channel." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.cancel": { + "defaultMessage": "Cancel" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.contentError": { + "defaultMessage": "Unable to load the content of this document." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.contentLoading": { + "defaultMessage": "Loading content" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.delete": { + "defaultMessage": "Delete" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.deleteMessage": { + "defaultMessage": "\"{name}\" will be permanently removed from this API. This can’t be undone." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.deleteTitle": { + "defaultMessage": "Delete document?" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.deleted": { + "defaultMessage": "Deleted \"{name}\"." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.edit": { + "defaultMessage": "Edit" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.empty": { + "defaultMessage": "This document has no content." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.loadError": { + "defaultMessage": "Unable to load this document." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.loading": { + "defaultMessage": "Loading document" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.notFound": { + "defaultMessage": "This document no longer exists. It may have been deleted." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentViewer.unsupported": { + "defaultMessage": "This document’s format can’t be previewed here." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.create": { + "defaultMessage": "Create Document", + "description": "Button that opens the form for a new API document. Verb phrase." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.empty": { + "defaultMessage": "No Documents available for this API" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.loadError": { + "defaultMessage": "Unable to load the documents for this API." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.loading": { + "defaultMessage": "Loading documents" + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.subtitle": { + "defaultMessage": "Guides, samples and support resources that describe this API." + }, + "apiControlPlane.pages.appShell.appShellPages.develop.documents.DocumentsBrowser.title": { + "defaultMessage": "Documents" + }, "apiControlPlane.pages.appShell.appShellPages.develop.policies.AttachedPolicyList.addPolicy": { "defaultMessage": "Add Policy", "description": "Button that attaches a policy from the catalog. Verb phrase." @@ -2290,14 +2540,6 @@ "apiControlPlane.pages.appShell.appShellPages.portals.utils.publicationForm.versionRequired": { "defaultMessage": "Enter a version." }, - "apiControlPlane.pages.appShell.appShellPages.projects.ProjectListPage.gridView": { - "defaultMessage": "Grid view", - "description": "Accessible label for the button switching to the card grid." - }, - "apiControlPlane.pages.appShell.appShellPages.projects.ProjectListPage.listView": { - "defaultMessage": "List view", - "description": "Accessible label for the button switching to the table rows." - }, "apiControlPlane.pages.appShell.appShellPages.projects.ProjectsList.deleteLabel": { "defaultMessage": "Delete {name}", "description": "Accessible label for the delete button on a project row." @@ -3934,10 +4176,6 @@ "defaultMessage": "List view", "description": "Accessible label for the button switching to compact rows." }, - "project.card.actionsLabel": { - "defaultMessage": "Project actions", - "description": "Accessible label for the button opening the card overflow menu." - }, "project.card.apiCount": { "defaultMessage": "{count, plural, one {# API} other {# APIs}}" }, @@ -3949,9 +4187,6 @@ "defaultMessage": "DEFAULT", "description": "Badge marking the organization’s default project." }, - "project.card.delete": { - "defaultMessage": "Delete" - }, "project.card.deleteNamed": { "defaultMessage": "Delete {name}" }, @@ -3973,9 +4208,6 @@ "defaultMessage": "Updated {relative}", "description": "Footer timestamp; {relative} is a phrase such as \"3 hours ago\"." }, - "project.list.count": { - "defaultMessage": "{count, plural, one {# project} other {# projects}}" - }, "project.list.createProjectButton": { "defaultMessage": "Create Project" }, @@ -4026,26 +4258,6 @@ "project.list.searchPlaceholder": { "defaultMessage": "Search projects" }, - "project.list.sort.nameAscending": { - "defaultMessage": "Name (A–Z)", - "description": "Sort option: alphabetical by project name, ascending." - }, - "project.list.sort.nameDescending": { - "defaultMessage": "Name (Z–A)", - "description": "Sort option: alphabetical by project name, descending." - }, - "project.list.sort.newest": { - "defaultMessage": "Newest first", - "description": "Sort option: by creation date, most recent project first." - }, - "project.list.sort.oldest": { - "defaultMessage": "Oldest first", - "description": "Sort option: by creation date, earliest project first." - }, - "project.list.sortLabel": { - "defaultMessage": "Sort by", - "description": "Label for the control choosing the project list order." - }, "project.list.subHeader.default": { "defaultMessage": "Select a project to manage APIs." }, diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailAvatar.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailAvatar.tsx new file mode 100644 index 0000000000..e7faf226b4 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailAvatar.tsx @@ -0,0 +1,86 @@ +/* + * 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 { ReactNode } from 'react'; +import { Avatar } from '@wso2/oxygen-ui'; +import { Boxes } from '@wso2/oxygen-ui-icons-react'; + +import { hairline } from '@/theme/receipes'; + +import { useApiThumbnail } from '@/api/resources/apiThumbnail'; +import { apiInitials } from '../utils/restApiDisplay'; + +type Props = { + apiType: string; + apiId: string | undefined; + displayName: string | undefined; + size: number; + /** Font size for the initials fallback. Defaults to size/2.5. */ + fontSize?: number | string; + /** Icon size for the empty-name fallback. Defaults to size/2. */ + iconSize?: number; +}; + +/** + * Avatar that renders the API's uploaded thumbnail when one is set, falling + * back to the two-letter name monogram (or a generic icon when no name is + * available). While the fetch is in flight the initials/icon are shown — a + * loading spinner here would flash on every row of a list. + * + * The underlying `useApiThumbnail` hook caches the Blob under + * `(org, apiType, apiId)`, so rendering this component in multiple places on + * the same page (listing + detail header, say) issues one network call. + */ +export function ApiThumbnailAvatar({ + apiType, + apiId, + displayName, + size, + fontSize, + iconSize, +}: Props): ReactNode { + const { url } = useApiThumbnail(apiType, apiId); + + const initials = apiInitials(displayName); + const fallback: ReactNode = initials || ; + + return ( + ({ + ...(url + ? { + bgcolor: 'background.paper', + border: hairline(theme), + borderColor: 'divider', + } + : { bgcolor: 'primary.light' }), + color: 'primary.contrastText', + flexShrink: 0, + fontSize: fontSize ?? Math.round(size / 2.5), + height: size, + width: size, + })} + variant="rounded" + > + {fallback} + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailManager.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailManager.test.tsx new file mode 100644 index 0000000000..a314c1d5bd --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailManager.test.tsx @@ -0,0 +1,238 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; +import { http as mswHttp, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiScopeProvider } from '@/api/core/ApiScopeProvider'; +import { resetHttpClient } from '@/api/core/http'; +import { apiUrl, failure, noContent, recorder, type Recorder } from '@/test/msw'; +import { server } from '@/test/server'; +import { renderWithProviders, screen, waitFor, within } from '@/test/utils'; +import { makeAuthState } from '@/test/mockAuthState'; +import { ApiThumbnailManager } from './ApiThumbnailManager'; + +// jsdom has no URL.createObjectURL / URL.revokeObjectURL — install stubs at +// module scope so React's passive cleanup effects can still revoke a URL +// scheduled by the component while the test harness unmounts it. +URL.createObjectURL = vi.fn(() => 'blob:test/mock'); +URL.revokeObjectURL = vi.fn(); + +/** + * Component tests for ApiThumbnailManager — the hover-overlay widget on the + * API cards / detail page that uploads, replaces, or removes an API's thumbnail. + * + * What these tests exercise (vs. the hook/endpoint tests already covering the + * underlying transport): + * - Camera / Trash controls render only when the user has the right permissions + * and the thumbnail exists for the Trash variant. + * - A non-JPEG/PNG upload is rejected client-side with a toast and never fires + * the PUT. Likewise a file over the 1 MiB cap. + * - A valid upload fires the PUT and surfaces a success toast. + * - Delete is a two-step flow (confirm dialog, then DELETE), not a single + * click, so an accidental Trash click doesn't blow away the thumbnail. + * - `disabled={true}` (gateway-managed APIs) hides the overlay entirely. + * + * Hover reveal itself is a CSS :hover pseudo-class — jsdom cannot resolve + * computed styles from pseudo-classes, so we assert the overlay buttons' DOM + * presence/absence rather than their rendered opacity. The buttons remain + * reachable to assistive tech regardless of :hover, so clicking them without + * hover first is representative, not a shortcut. + */ + +const ORG = 'api-platform-demo'; +const PROJECT = 'retail-apis'; +const API_TYPE = 'rest-api'; +const API_ID = 'orders-api'; +const PATH = `/apis/${API_TYPE}/${API_ID}/thumbnail`; +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +let requests: Recorder; + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); + // Default: the API has no thumbnail yet. Individual tests override. + server.use(failure('get', PATH, 404, 'NOT_FOUND')); +}); + +function renderManager( + props: Partial> = {}, + options: { permissionMode?: 'permissive' | 'enforce'; scopes?: string[] } = {}, +) { + const authState = options.scopes + ? makeAuthState({ + user: { name: 'Test User', email: 'test.user@example.com', scopes: options.scopes }, + }) + : undefined; + return renderWithProviders( + + + , + { permissionMode: options.permissionMode ?? 'permissive', authState }, + ); +} + +describe('ApiThumbnailManager — overlay visibility', () => { + it('exposes the Camera button when the caller may upsert the thumbnail', async () => { + renderManager(); + // Default permissive scope lets useCan('UpsertAPIThumbnail') return true. + expect(await screen.findByRole('button', { name: /change thumbnail/i })).toBeInTheDocument(); + }); + + it('hides the Trash button while no thumbnail exists (nothing to remove)', async () => { + renderManager(); + await screen.findByRole('button', { name: /change thumbnail/i }); + expect(screen.queryByRole('button', { name: /remove thumbnail/i })).not.toBeInTheDocument(); + }); + + it('exposes the Trash button once a thumbnail is cached', async () => { + server.resetHandlers(); + server.use( + mswHttp.get(apiUrl(PATH), () => + new HttpResponse(PNG_BYTES, { headers: { 'Content-Type': 'image/png' } }), + ), + ); + renderManager(); + expect(await screen.findByRole('button', { name: /remove thumbnail/i })).toBeInTheDocument(); + }); + + it('hides every overlay control when disabled=true (gateway-managed APIs)', async () => { + renderManager({ disabled: true }); + // Give any async hook work a tick before asserting absence. + await Promise.resolve(); + expect(screen.queryByRole('button', { name: /change thumbnail/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /remove thumbnail/i })).not.toBeInTheDocument(); + }); + + it('hides the overlay when the caller lacks the UpsertAPIThumbnail scope', async () => { + // Enforce mode + a caller with no granted scopes → every useCan returns false. + renderManager({}, { permissionMode: 'enforce', scopes: [] }); + await Promise.resolve(); + expect(screen.queryByRole('button', { name: /change thumbnail/i })).not.toBeInTheDocument(); + }); +}); + +// Directly dispatches a `change` event on the hidden file input. user-event's +// `upload()` goes through a click + pointer-events check that fails on the +// `display: none` input the component uses for its OS file picker. +async function selectFile(file: File) { + const fileInput = document.querySelector('input[type="file"]'); + if (!(fileInput instanceof HTMLInputElement)) { + throw new Error('hidden file input not found'); + } + fireEvent.change(fileInput, { target: { files: [file] } }); +} + +describe('ApiThumbnailManager — upload validation', () => { + it('rejects a non-JPEG/PNG file client-side without firing the PUT', async () => { + // Register a put handler that would record if it were ever hit — the client- + // side mime check must stop the mutation before that happens. + server.use(noContent('put', PATH, { record: requests })); + renderManager(); + + await screen.findByRole('button', { name: /change thumbnail/i }); + await selectFile(new File(['not an image'], 'notes.txt', { type: 'text/plain' })); + + expect( + await screen.findByText(/Only JPEG and PNG images are supported/i), + ).toBeInTheDocument(); + expect(requests.count()).toBe(0); + }); + + it('rejects a file over the 1 MiB cap client-side without firing the PUT', async () => { + server.use(noContent('put', PATH, { record: requests })); + renderManager(); + + await screen.findByRole('button', { name: /change thumbnail/i }); + // File.size is computed from the Blob parts — a Uint8Array of the right + // length is all the pre-flight size check inspects; the bytes themselves + // don't need to be a real image. + await selectFile(new File([new Uint8Array(2 * 1024 * 1024)], 'huge.png', { type: 'image/png' })); + + expect(await screen.findByText(/image must be 1 MB or smaller/i)).toBeInTheDocument(); + expect(requests.count()).toBe(0); + }); + + it('fires PUT and surfaces a success toast on a valid upload', async () => { + server.use(noContent('put', PATH, { record: requests })); + renderManager(); + + await screen.findByRole('button', { name: /change thumbnail/i }); + await selectFile(new File([PNG_BYTES], 'icon.png', { type: 'image/png' })); + + await waitFor(() => expect(requests.count()).toBe(1)); + expect(requests.last()?.method).toBe('PUT'); + expect(await screen.findByText(/Thumbnail updated/i)).toBeInTheDocument(); + }); +}); + +describe('ApiThumbnailManager — delete flow', () => { + function serveExistingThumbnail() { + server.resetHandlers(); + server.use( + mswHttp.get(apiUrl(PATH), () => + new HttpResponse(PNG_BYTES, { headers: { 'Content-Type': 'image/png' } }), + ), + ); + } + + // The overlay has `pointer-events: none` until the parent is hovered. jsdom + // cannot resolve :hover, so user-event refuses to click. Fire the event + // directly — React's onClick still runs, which is what the test is about. + it('opens the confirm dialog rather than deleting on a single Trash click', async () => { + serveExistingThumbnail(); + const spy = vi.fn(); + server.use( + mswHttp.delete(apiUrl(PATH), () => { + spy(); + return new HttpResponse(null, { status: 204 }); + }), + ); + renderManager(); + + fireEvent.click(await screen.findByRole('button', { name: /remove thumbnail/i })); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('fires DELETE and shows a success toast after dialog confirmation', async () => { + serveExistingThumbnail(); + server.use(noContent('delete', PATH, { record: requests })); + renderManager(); + + fireEvent.click(await screen.findByRole('button', { name: /remove thumbnail/i })); + const dialog = await screen.findByRole('dialog'); + // Scope to the dialog: the overlay's Trash button is also labelled + // "Remove thumbnail", so an unscoped match would be ambiguous. The + // dialog's own confirm button is labelled just "Remove". + fireEvent.click(within(dialog).getByRole('button', { name: /^remove$/i })); + + await waitFor(() => expect(requests.count()).toBe(1)); + expect(requests.last()?.method).toBe('DELETE'); + expect(await screen.findByText(/Thumbnail removed/i)).toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailManager.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailManager.tsx new file mode 100644 index 0000000000..595f2811a4 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiThumbnailManager.tsx @@ -0,0 +1,248 @@ +/* + * 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 { useRef, useState } from 'react'; +import { Box, IconButton, Tooltip } from '@wso2/oxygen-ui'; +import { Camera, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { + useApiThumbnail, + useDeleteApiThumbnail, + useUpsertApiThumbnail, +} from '@/api/resources/apiThumbnail'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { useNotifications } from '@/components/Notifications'; +import { useCan } from '@/permissions/useCan'; +import { ApiThumbnailAvatar } from './ApiThumbnailAvatar'; + +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png'] as const; +/** Mirror of the server cap (`DefaultThumbnailMaxBytes`). Keep in sync. */ +const MAX_BYTES = 1024 * 1024; // 1 MiB + +const messages = defineMessages({ + changeThumbnail: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.changeThumbnail', + defaultMessage: 'Change thumbnail', + }, + removeThumbnail: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.removeThumbnail', + defaultMessage: 'Remove thumbnail', + }, + confirmDeleteTitle: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.confirmDeleteTitle', + defaultMessage: 'Remove API thumbnail', + }, + confirmDeleteMessage: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.confirmDeleteMessage', + defaultMessage: + 'Are you sure you want to delete the thumbnail of this API?', + }, + confirmDeleteButton: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.confirmDeleteButton', + defaultMessage: 'Remove', + }, + errorType: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.errorType', + defaultMessage: 'Only JPEG and PNG images are supported.', + }, + errorSize: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.errorSize', + defaultMessage: 'The image must be 1 MB or smaller.', + }, + uploadSuccess: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.uploadSuccess', + defaultMessage: 'Thumbnail updated.', + }, + deleteSuccess: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.ApiThumbnailManager.deleteSuccess', + defaultMessage: 'Thumbnail removed.', + }, +}); + +type Props = { + apiType: string; + apiId: string; + displayName: string | undefined; + size: number; + fontSize?: number | string; + iconSize?: number; + /** When true, hides the hover affordance entirely — e.g. gateway-managed APIs. */ + disabled?: boolean; +}; + +/** + * Avatar with a hover-only overlay for managing the API's thumbnail. + */ +export function ApiThumbnailManager({ + apiType, + apiId, + displayName, + size, + fontSize, + iconSize, + disabled = false, +}: Props) { + const intl = useIntl(); + const { notify } = useNotifications(); + + const inputRef = useRef(null); + const [confirmingDelete, setConfirmingDelete] = useState(false); + + const { url: currentUrl } = useApiThumbnail(apiType, apiId); + const canUpsert = useCan('UpsertAPIThumbnail'); + const canDelete = useCan('DeleteAPIThumbnail'); + const upsertMutation = useUpsertApiThumbnail(); + const deleteMutation = useDeleteApiThumbnail(); + + const showOverlay = !disabled && canUpsert; + const showRemove = showOverlay && canDelete && Boolean(currentUrl); + const busy = upsertMutation.isPending || deleteMutation.isPending; + + const pickFile = () => inputRef.current?.click(); + + const onFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + // Reset the input value so re-picking the same file still fires change. + event.target.value = ''; + if (!file) return; + + if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) { + notify(intl.formatMessage(messages.errorType), 'error'); + return; + } + if (file.size > MAX_BYTES) { + notify(intl.formatMessage(messages.errorSize), 'error'); + return; + } + try { + await upsertMutation.mutateAsync({ apiType, apiId, file }); + notify(intl.formatMessage(messages.uploadSuccess), 'success'); + } catch { + /* Global onMutationError surfaces the detail; nothing extra here. */ + } + }; + + const onConfirmDelete = async () => { + try { + await deleteMutation.mutateAsync({ apiType, apiId }); + notify(intl.formatMessage(messages.deleteSuccess), 'success'); + } catch { + /* Global onMutationError handles the toast. */ + } finally { + setConfirmingDelete(false); + } + }; + + return ( + <> + + + {showOverlay && ( + + + {/* Span wrapper lets the Tooltip anchor on a disabled button. */} + + + + + + + {showRemove && ( + + + setConfirmingDelete(true)} + size="small" + sx={{ color: 'common.white' }} + > + + + + + )} + + )} + + + + + setConfirmingDelete(false)} + onConfirm={onConfirmDelete} + open={confirmingDelete} + title={intl.formatMessage(messages.confirmDeleteTitle)} + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListPage.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListPage.test.tsx index 99ffdc8ed4..89474e2c1e 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListPage.test.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListPage.test.tsx @@ -85,6 +85,10 @@ function renderPage() { beforeEach(() => { requests = recorder(); resetHttpClient(); + + server.use( + http.get(apiUrl('/apis/:apiType/:apiId/thumbnail'), () => new HttpResponse(null, { status: 204 })) + ); }); describe('ApiListPage', () => { diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListView.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListView.tsx index ca8360e9a3..aec819d0d6 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListView.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/ApiListView.tsx @@ -19,16 +19,17 @@ import { Box, Card, Stack, Typography } from '@wso2/oxygen-ui'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { REST_API_TYPE } from '@/api/resources/apiPublications'; import type { RestApi } from '@/api/resources/restApis'; import { openableProps } from '@/components/openable'; import { focusRingSx } from '@/theme'; import { apiDescriptionSx, ApiDeleteButton, - ApiKindAvatar, ApiKindChip, UpdatedLabel, } from './components/RestApiChips'; +import { ApiThumbnailAvatar } from '../components/ApiThumbnailAvatar'; import { useCan } from '@/permissions/useCan'; const AVATAR_SIZE = 40; @@ -85,7 +86,12 @@ function ApiRow({ api, onOpen, onDelete }: ApiRowProps) { > {/* `minWidth: 0` lets long names truncate. */} - + {api.displayName} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/components/ApiCard.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/components/ApiCard.tsx index d8eaa0ebd5..137880c853 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/components/ApiCard.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/listing/components/ApiCard.tsx @@ -19,17 +19,18 @@ import { Box, Card, CardContent, Divider, Stack, Typography } from '@wso2/oxygen-ui'; import { useIntl } from 'react-intl'; +import { REST_API_TYPE } from '@/api/resources/apiPublications'; import type { RestApi } from '@/api/resources/restApis'; import { openableProps } from '@/components/openable'; import { focusRingSx, interactiveCardSx } from '@/theme'; import { apiDescriptionSx, ApiDeleteButton, - ApiKindAvatar, ApiKindChip, UpdatedLabel, VersionChip, } from './RestApiChips'; +import { ApiThumbnailAvatar } from '../../components/ApiThumbnailAvatar'; import { useCan } from '@/permissions/useCan'; type ApiCardProps = { @@ -65,7 +66,12 @@ export function ApiCard({ api, onOpen, onDelete }: ApiCardProps) { - + {api.displayName} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/ApiDetailPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/ApiDetailPage.tsx index 8a123e6cde..a491ea9c6a 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/ApiDetailPage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/ApiDetailPage.tsx @@ -18,7 +18,6 @@ import { useMemo } from 'react'; import { - Avatar, Box, Button, Card, @@ -28,11 +27,12 @@ import { Tooltip, Typography, } from '@wso2/oxygen-ui'; -import { Boxes, Clock, Copy, Edit, Lock, Rocket } from '@wso2/oxygen-ui-icons-react'; +import { Clock, Copy, Edit, Lock, Rocket } from '@wso2/oxygen-ui-icons-react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link as RouterLink } from 'react-router-dom'; import { AppPage } from '@/components/AppPage'; +import { REST_API_TYPE } from '@/api/resources/apiPublications'; import { useRestApi } from '@/api/resources/restApis'; import type { Gateway } from '@/api/resources/gateways'; import { useDeployments } from '@/api/resources/restApis/deployments'; @@ -41,8 +41,8 @@ import { ErrorState, LoadingState } from '@/components/StateViews'; import { useFormatters } from '@/i18n/useFormatters'; import { routes } from '@/routes/paths'; import { useConsoleScope } from '@/scope/ConsoleScopeProvider'; +import { ApiThumbnailManager } from '../components/ApiThumbnailManager'; import { ApiKindChip, VersionChip } from '../listing/components/RestApiChips'; -import { apiInitials } from '../utils/restApiDisplay'; import { OverviewTab } from './OverviewTab'; import { ProgressBanner } from './ProgressBanner'; import { Can } from '@/permissions/Can'; @@ -236,19 +236,15 @@ function ApiDetailPageContent() { minWidth: 0, }} > - - {apiInitials(displayName) || } - + {/* Identity: name, version, lifecycle, and whether this console diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.test.tsx new file mode 100644 index 0000000000..723c810f5f --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { describe, expect, it, beforeEach } from 'vitest'; + +import { ApiScopeProvider } from '@/api/core/ApiScopeProvider'; +import { resetHttpClient } from '@/api/core/http'; +import type { ApiDocumentMetadata } from '@/api/resources/apiDocuments'; +import { routes } from '@/routes/paths'; +import { makeConsoleScope } from '@/test/mockScope'; +import { collection } from '@/test/msw'; +import { server } from '@/test/server'; +import { renderWithProviders, screen } from '@/test/utils'; +import { DocumentsPanel } from './DocumentsPanel'; + +const ORG = 'api-platform-demo'; +const PROJECT = 'retail-apis'; +const API = 'orders-api'; +const COLLECTION = `/apis/rest-api/${API}/docs`; + +const aDocument = (id: string): ApiDocumentMetadata => ({ + displayName: `Doc ${id}`, + id, + type: 'HowTo', + updatedAt: '2026-09-28T10:00:00Z', + updatedBy: 'admin', +}); + +function renderPanel() { + return renderWithProviders( + + + , + { + scope: makeConsoleScope({ + params: { apiHandler: API, orgHandle: ORG, projectHandler: PROJECT }, + }), + }, + ); +} + +beforeEach(() => resetHttpClient()); + +describe('overview DocumentsPanel', () => { + it('offers to create the first document when there are none', async () => { + server.use(collection(COLLECTION, [])); + renderPanel(); + + expect(await screen.findByText('No Documents available for this API')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /View More/ })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Create Document/ })).toHaveAttribute( + 'href', + `${routes.apiDevelopDocuments(ORG, PROJECT, API)}?mode=create`, + ); + }); + + it('links each row to that document on the Documents page', async () => { + server.use(collection(COLLECTION, [aDocument('one')])); + renderPanel(); + + const row = await screen.findByRole('link', { name: /Doc one/ }); + expect(row).toHaveAttribute('href', `${routes.apiDevelopDocuments(ORG, PROJECT, API)}?doc=one`); + // Nothing more to see than what is listed, so no "View More" — and documents exist, so no create link. + expect(screen.queryByRole('link', { name: /View More/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Create Document/ })).not.toBeInTheDocument(); + }); + + it('shows five documents and sends the rest to the Documents page', async () => { + server.use( + collection( + COLLECTION, + Array.from({ length: 7 }, (_, index) => aDocument(`${index + 1}`)), + ), + ); + renderPanel(); + + expect(await screen.findByRole('link', { name: /Doc 5/ })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Doc 6/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /View More/ })).toHaveAttribute( + 'href', + routes.apiDevelopDocuments(ORG, PROJECT, API), + ); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.tsx index 08f71c26f5..95150327cd 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/DocumentsPanel.tsx @@ -1,71 +1,212 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * 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 { Box, Card, Chip, Divider, Stack, Typography } from '@wso2/oxygen-ui'; -import { FileText } from '@wso2/oxygen-ui-icons-react'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { Box, Card, Chip, Divider, Link, ListItemButton, Stack, Typography } from '@wso2/oxygen-ui'; +import { ChevronRight, FileText, Plus } from '@wso2/oxygen-ui-icons-react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link as RouterLink } from 'react-router-dom'; -import documents from './mockDocuments.json'; +import { useApiDocuments } from '@/api/resources/apiDocuments'; +import { useFormatters } from '@/i18n/useFormatters'; +import { REST_API_TYPE } from '@/api/resources/apiPublications'; +import { routes } from '@/routes/paths'; +import { Can } from '@/permissions'; +import { useConsoleScope } from '@/scope/ConsoleScopeProvider'; +import { documentTypeName } from '../../develop/documents/documentTypes'; +import { documentsSearch } from '../../develop/documents/documentsSearch'; const messages = defineMessages({ title: { id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.title', defaultMessage: 'Documents', }, - description: { - id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.description', - defaultMessage: 'Guides, references, and specifications published with this API.', + viewMore: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.viewMore', + defaultMessage: 'View More', + description: 'Opens the API’s Documents page to see every document.', + }, + create: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.create', + defaultMessage: 'Create Document', + description: 'Opens the form for a new API document. Shown when the API has none.', + }, + empty: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.empty', + defaultMessage: 'No Documents available for this API', + }, + loading: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.loading', + defaultMessage: 'Loading documents…', }, updated: { id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.updated', - defaultMessage: '2 days ago', - description: 'Temporary relative update time for demo document data.', + defaultMessage: 'Updated {when}', + description: '{when} is a relative time, e.g. "2 days ago".', + }, + loadError: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.overview.DocumentsPanel.loadError', + defaultMessage: 'Unable to load documents.', }, }); +/** Icon and label of a header link sit on one line, centred on each other. */ +const HEADER_LINK_SX = { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + gap: 0.5, +} as const; + +/** Documents shown on the overview; the rest are a click away on the Documents page. */ +const PREVIEW_COUNT = 5; + +/** + * The API's most recently updated documents. Each row opens that document on + * Develop › Documents, and "View More" opens the full list there. + */ export function DocumentsPanel() { + const intl = useIntl(); + const { relativeTime } = useFormatters(); + const { params } = useConsoleScope(); + const { apiHandler, orgHandle, projectHandler } = params; + const documentsQuery = useApiDocuments(REST_API_TYPE, apiHandler, { limit: PREVIEW_COUNT }); + + const documentsPath = + orgHandle && projectHandler && apiHandler + ? routes.apiDevelopDocuments(orgHandle, projectHandler, apiHandler) + : undefined; + + const documents = documentsQuery.data?.list ?? []; + const total = documentsQuery.data?.pagination.total ?? 0; + const loaded = !documentsQuery.isPending && !documentsQuery.error; + return ( - - + + - - - - - - }> - {documents.map((document) => ( - PREVIEW_COUNT && ( + - - - - {document.title} - - - {document.description} - - - - + + + )} + {documentsPath && loaded && total === 0 && ( + + - - - - ))} + + + + + )} + + + {documentsQuery.isPending ? ( + + + + ) : documentsQuery.error ? ( + + + + ) : total === 0 ? ( + + + + ) : ( + } + sx={{ listStyle: 'none', m: 0, p: 0 }} + > + {documents.map((document) => ( + + + + {/* + * `noWrap` + the bounded `minmax(10rem, 24rem)` grid column + * truncates long titles with an ellipsis; `minWidth: 0` lets the + * cell shrink below its content width. The full name is kept + * in `title` so it's still readable on hover. + */} + + {document.displayName} + + + + + + + + ))} + + )} ); } diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/OverviewTab.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/OverviewTab.tsx index 0c11959fc4..06264c9e8f 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/OverviewTab.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/OverviewTab.tsx @@ -23,8 +23,8 @@ import type { RestApi } from '@/api/resources/restApis'; import type { Deployment } from '@/api/resources/restApis/deployments'; import { ApiKeysPanel } from './ApiKeysPanel'; import { DeployedGatewaysPanel } from './DeployedGatewaysPanel'; +import { DocumentsPanel } from './DocumentsPanel'; import { EndpointsPanel } from './EndpointsPanel'; -// import { DocumentsPanel } from './DocumentsPanel'; import { InvokeUrlPanel } from './InvokeUrlPanel'; import { ResourcesPanel } from './ResourcesPanel'; @@ -52,8 +52,7 @@ export function OverviewTab({ - {/* Uncomment DocumentsPanel when documents should be shown on the overview. */} - {/* */} + diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/mockDocuments.json b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/mockDocuments.json deleted file mode 100644 index 34283ff42b..0000000000 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/overview/mockDocuments.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - { - "id": "getting-started", - "title": "Getting started", - "description": "Authenticate, make your first call, and read a response.", - "type": "How to" - }, - { - "id": "authentication-guide", - "title": "Authentication guide", - "description": "OAuth2 scopes, API keys, and token lifetimes.", - "type": "How to" - }, - { - "id": "api-reference", - "title": "API reference", - "description": "Resource paths, request formats, and response models.", - "type": "Reference" - } -] diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/develop/definition/DefinitionPanel.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/definition/DefinitionPanel.test.tsx new file mode 100644 index 0000000000..dc858a8864 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/develop/definition/DefinitionPanel.test.tsx @@ -0,0 +1,257 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Monaco is heavy and jsdom-hostile — swap it for a plain